未知(继上次中断)

This commit is contained in:
Cheng Zhou
2026-02-04 10:52:34 +08:00
parent 8e258d21a7
commit 737f84bf54
99 changed files with 5497 additions and 2143 deletions
+17
View File
@@ -1,6 +1,17 @@
import { apiGet } from "./axios";
import type { ApiListResponse } from "../types/api";
import { VisitLostItem } from "../types/visits";
export interface CenterSummaryItem {
id: string;
name: string;
stage: string;
stage_status?: string;
actual_enrolled: number;
planned_enrolled: number;
}
export const fetchProgress = (studyId: string) =>
apiGet(`/api/v1/studies/${studyId}/dashboard/progress`);
@@ -9,3 +20,9 @@ export const fetchFinanceSummary = (studyId: string) =>
export const fetchOverdueAesCount = (studyId: string) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params: { overdue: true, limit: 1 } });
export const fetchLostVisits = (studyId: string, params?: Record<string, any>) =>
apiGet<VisitLostItem[]>(`/api/v1/studies/${studyId}/dashboard/lost-visits`, { params });
export const fetchCenterSummary = (studyId: string) =>
apiGet<CenterSummaryItem[]>(`/api/v1/studies/${studyId}/dashboard/center-summary`);
+2 -17
View File
@@ -6,7 +6,6 @@ import type {
DocumentDetail,
DocumentSummary,
DocumentVersion,
WorkflowTemplate,
} from "../types/documents";
export const fetchDocuments = (params: Record<string, any>) =>
@@ -26,31 +25,17 @@ export const uploadDocumentVersion = (documentId: string, payload: FormData) =>
headers: { "Content-Type": "multipart/form-data" },
});
export const submitVersion = (versionId: string, payload: { template_id: string; comment?: string }) =>
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/submit`, payload);
export const approveVersion = (versionId: string, payload: { comment?: string }) =>
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/approve`, payload);
export const rejectVersion = (versionId: string, payload: { comment?: string }) =>
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/reject`, payload);
export const makeVersionEffective = (versionId: string) =>
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/make-effective`);
export const downloadDocumentVersion = (versionId: string) =>
apiGet<Blob>(`/api/v1/versions/${versionId}/download`, {
responseType: "blob",
});
export const listWorkflowTemplates = (params?: { trial_id?: string }) =>
apiGet<WorkflowTemplate[]>(`/api/v1/workflow-templates`, { params });
export const deleteDocumentVersion = (versionId: string) =>
apiDelete(`/api/v1/versions/${versionId}`);
export const listDistributions = (versionId: string) =>
apiGet<Distribution[]>(`/api/v1/versions/${versionId}/distributions`);
export const createDistributions = (versionId: string, payload: Record<string, any>) =>
apiPost<Distribution[]>(`/api/v1/versions/${versionId}/distributions`, payload);
export const createAcknowledgement = (distributionId: string, payload: { ack_type: string; evidence?: string }) =>
apiPost<Acknowledgement>(`/api/v1/distributions/${distributionId}/acknowledgements`, payload);
+5
View File
@@ -0,0 +1,5 @@
import { apiGet } from "./axios";
import type { NotificationItem } from "../types/notifications";
export const listNotifications = (studyId: string, params?: Record<string, any>) =>
apiGet<NotificationItem[]>(`/api/v1/studies/${studyId}/notifications`, { params });
+161 -91
View File
@@ -1,29 +1,36 @@
<template>
<el-card class="kpi-card" :class="[`type-${type}`]" shadow="hover">
<div class="kpi-header">
<div class="kpi-title-wrapper">
<div class="kpi-icon-wrapper" v-if="icon">
<el-icon class="kpi-icon"><component :is="icon" /></el-icon>
<div class="kpi-card" :class="[`type-${type}`]">
<!-- 顶部标签区域 -->
<div class="kpi-badge">
<div class="kpi-badge-icon" v-if="icon">
<el-icon><component :is="icon" /></el-icon>
</div>
<span class="kpi-badge-text">{{ badgeText }}</span>
</div>
<!-- 标题区域 -->
<div class="kpi-main">
<h3 class="kpi-title">{{ title }}</h3>
<p class="kpi-subtitle" v-if="subtext">{{ subtext }}</p>
</div>
<!-- 底部数值区域 -->
<div class="kpi-footer">
<div class="kpi-value-wrapper">
<div v-if="loading" class="kpi-loading">
<el-skeleton animated :style="{ '--el-skeleton-color': 'rgba(255,255,255,0.2)', '--el-skeleton-to-color': 'rgba(255,255,255,0.4)' }">
<template #template>
<el-skeleton-item variant="h1" style="width: 80px; height: 32px" />
</template>
</el-skeleton>
</div>
<span class="kpi-title">{{ title }}</span>
<template v-else>
<span class="kpi-value">{{ value }}</span>
<span v-if="unit" class="kpi-unit">{{ unit }}</span>
</template>
</div>
<slot name="header-suffix"></slot>
</div>
<div class="kpi-content">
<div v-if="loading" class="kpi-loading">
<el-skeleton animated>
<template #template>
<el-skeleton-item variant="h1" style="width: 60%" />
</template>
</el-skeleton>
</div>
<div v-else class="kpi-value-container">
<span class="kpi-value">{{ value }}</span>
<span v-if="unit" class="kpi-unit">{{ unit }}</span>
</div>
<div v-if="subtext" class="kpi-subtext">{{ subtext }}</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
@@ -37,123 +44,186 @@ interface Props {
icon?: any;
unit?: string;
type?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
badge?: string;
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
});
// 根据类型生成默认徽章文字
const badgeText = computed(() => {
if (props.badge) return props.badge;
const badgeMap: Record<string, string> = {
primary: '统计',
success: '支出',
warning: '待办',
danger: '警告',
info: '信息',
default: '概览',
};
return badgeMap[props.type] || '概览';
});
</script>
<style scoped>
.kpi-card {
border: 1px solid var(--ctms-border-color);
transition: all 0.3s ease;
height: 100%;
border-radius: 12px;
overflow: hidden;
position: relative;
--el-card-padding: 12px 16px;
}
.kpi-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 4px;
padding: 20px;
border-radius: 16px;
height: 100%;
background-color: transparent;
transition: background-color 0.3s ease;
min-height: 140px;
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-sizing: border-box;
}
.kpi-card.type-primary::before { background-color: var(--el-color-primary); }
.kpi-card.type-success::before { background-color: var(--el-color-success); }
.kpi-card.type-warning::before { background-color: var(--el-color-warning); }
.kpi-card.type-danger::before { background-color: var(--el-color-danger); }
.kpi-card.type-info::before { background-color: var(--el-color-info); }
/* 默认样式 - 深灰色 */
.kpi-card.type-default {
background: linear-gradient(135deg, #2d2d2d 0%, #1a1a1a 100%);
color: #ffffff;
}
/* 主要样式 - 蓝色渐变 */
.kpi-card.type-primary {
background: linear-gradient(135deg, #4ECDC4 0%, #44A8B3 50%, #2C8C99 100%);
color: #ffffff;
}
/* 成功样式 - 黄/橙色渐变 */
.kpi-card.type-success {
background: linear-gradient(135deg, #F7DC6F 0%, #F4C430 50%, #E6B800 100%);
color: #1a1a1a;
}
/* 警告样式 - 红/珊瑚色渐变 */
.kpi-card.type-warning {
background: linear-gradient(135deg, #FF6B6B 0%, #EE5A52 50%, #DC4040 100%);
color: #ffffff;
}
/* 危险样式 - 深红色渐变 */
.kpi-card.type-danger {
background: linear-gradient(135deg, #C0392B 0%, #96281B 100%);
color: #ffffff;
}
/* 信息样式 - 深灰色 */
.kpi-card.type-info {
background: linear-gradient(135deg, #3d3d3d 0%, #2a2a2a 100%);
color: #ffffff;
}
/* 悬停效果 */
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25);
}
.kpi-header {
display: flex;
justify-content: space-between;
/* 顶部徽章 */
.kpi-badge {
display: inline-flex;
align-items: center;
margin-bottom: 8px;
gap: 6px;
padding: 4px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(4px);
width: fit-content;
margin-bottom: 12px;
}
.kpi-title-wrapper {
display: flex;
align-items: center;
gap: 8px;
.type-success .kpi-badge {
background: rgba(0, 0, 0, 0.12);
}
.kpi-icon-wrapper {
.kpi-badge-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background-color: var(--ctms-bg-color-page);
color: var(--ctms-text-secondary);
font-size: 12px;
opacity: 0.9;
}
.type-primary .kpi-icon-wrapper { color: var(--el-color-primary); background-color: var(--el-color-primary-light-9); }
.type-success .kpi-icon-wrapper { color: var(--el-color-success); background-color: var(--el-color-success-light-9); }
.type-warning .kpi-icon-wrapper { color: var(--el-color-warning); background-color: var(--el-color-warning-light-9); }
.type-danger .kpi-icon-wrapper { color: var(--el-color-danger); background-color: var(--el-color-danger-light-9); }
.type-info .kpi-icon-wrapper { color: var(--el-color-info); background-color: var(--el-color-info-light-9); }
.kpi-title {
color: var(--ctms-text-secondary);
font-size: 14px;
font-weight: 500;
.kpi-badge-text {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.9;
}
.kpi-icon {
font-size: 16px;
}
.kpi-content {
/* 主内容区域 */
.kpi-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.kpi-value-container {
.kpi-title {
font-size: 18px;
font-weight: 600;
margin: 0;
line-height: 1.3;
letter-spacing: -0.01em;
}
.kpi-subtitle {
font-size: 13px;
margin: 0;
opacity: 0.75;
font-weight: 400;
}
/* 底部数值区域 */
.kpi-footer {
margin-top: auto;
padding-top: 16px;
}
.kpi-value-wrapper {
display: flex;
align-items: baseline;
gap: 4px;
}
.kpi-value {
font-size: 32px;
font-size: 36px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.2;
font-family: var(--el-font-family);
letter-spacing: -0.02em;
line-height: 1;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.kpi-unit {
font-size: 14px;
color: var(--ctms-text-secondary);
font-size: 16px;
font-weight: 500;
margin-left: 2px;
}
.kpi-subtext {
color: var(--ctms-text-secondary);
font-size: 13px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--ctms-border-color-lighter);
opacity: 0.8;
margin-left: 4px;
}
.kpi-loading {
height: 38px;
height: 36px;
display: flex;
align-items: center;
}
/* 响应式调整 */
@media (max-width: 768px) {
.kpi-card {
min-height: 120px;
padding: 16px;
}
.kpi-title {
font-size: 16px;
}
.kpi-value {
font-size: 28px;
}
}
</style>
+267 -44
View File
@@ -1,6 +1,6 @@
<template>
<el-container class="layout-container">
<el-aside :width="isCollapsed ? '72px' : '240px'" class="layout-aside" :class="{ collapsed: isCollapsed }" v-if="isAdmin || study.currentStudy">
<el-aside :width="isCollapsed ? '68px' : '200px'" class="layout-aside" :class="{ collapsed: isCollapsed }" v-if="isAdmin || study.currentStudy">
<div class="aside-logo">
<div class="logo-icon"></div>
<span class="logo-text">{{ TEXT.common.appName }}</span>
@@ -100,9 +100,50 @@
</svg>
</el-icon>
</el-button>
<!-- 移入 Header 的面包屑 -->
<div v-if="breadcrumbs.length" class="header-breadcrumb">
<template v-for="(item, index) in breadcrumbs" :key="index">
<!-- 带下拉的选择器类型 -->
<el-dropdown v-if="item.hasDropdown" trigger="click" @command="handleBreadcrumbCommand">
<div class="breadcrumb-item-wrapper is-link">
<span class="breadcrumb-item-text is-link-text">{{ item.label }}</span>
<el-icon class="breadcrumb-dropdown-icon"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="breadcrumb-dropdown-menu">
<template v-if="item.type === 'study'">
<el-dropdown-item v-for="s in studies" :key="s.id" :command="{ type: 'study', value: s }" :class="{ active: study.currentStudy?.id === s.id }">
{{ s.name }}
</el-dropdown-item>
</template>
<template v-else-if="item.type === 'site'">
<el-dropdown-item :command="{ type: 'site', value: null }" :class="{ active: !study.currentSite }">
{{ TEXT.common.labels.allSites || '所有中心' }}
</el-dropdown-item>
<el-dropdown-item v-for="s in sites" :key="s.id" :command="{ type: 'site', value: s }" :class="{ active: study.currentSite?.id === s.id }">
{{ s.name }}
</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 普通链接或静态文本 -->
<div
v-else
class="breadcrumb-item-wrapper"
:class="{ 'is-link': item.path && index < breadcrumbs.length - 1 }"
@click="item.path && index < breadcrumbs.length - 1 && router.push(item.path)"
>
<span class="breadcrumb-item-text" :class="{ 'is-link-text': index > 0 && index < breadcrumbs.length - 1 }">{{ item.label }}</span>
</div>
<span v-if="index < breadcrumbs.length - 1" class="breadcrumb-separator">/</span>
</template>
</div>
</div>
<div class="header-right">
<StudySelector />
<el-dropdown trigger="click" @command="onCommand" class="user-profile">
<span class="dropdown-trigger compact">
<el-avatar
@@ -131,6 +172,7 @@
</el-dropdown>
</div>
</el-header>
<el-main class="layout-main">
<div class="content-wrapper">
@@ -146,16 +188,18 @@
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import StudySelector from "./StudySelector.vue";
import { fetchStudies } from "../api/studies";
import { fetchSites } from "../api/sites";
import { TEXT } from "../locales";
import {
Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
const auth = useAuthStore();
const study = useStudyStore();
@@ -189,6 +233,121 @@ const activeMenu = computed(() => {
return path;
});
const studies = ref<any[]>([]);
const sites = ref<any[]>([]);
const loadStudies = async () => {
try {
const { data } = await fetchStudies();
studies.value = Array.isArray(data) ? data : (data as any).items || [];
} catch {}
};
const loadSites = async () => {
if (!study.currentStudy) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
sites.value = Array.isArray(data) ? data : (data as any).items || [];
} catch {
sites.value = [];
}
};
const breadcrumbs = computed(() => {
const items: any[] = [];
if (route.path.startsWith("/admin")) {
items.push({
label: TEXT.menu.admin,
path: "/admin/users"
});
} else if (study.currentStudy) {
// 1. 项目名
items.push({
label: (study.currentStudy.name || "").replace(/^示例项目[:]/, ''),
path: "/project/overview",
hasDropdown: true,
type: 'study'
});
// 2. 中心名 (逻辑增强:详情页优先显示具体中心,列表页显示筛选中心)
items.push({
label: study.viewContext?.siteName || study.currentSite?.name || "所有中心",
hasDropdown: true,
type: 'site'
});
// 3. 模块名 (逻辑增强:当在详情页时显示所属模块)
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" },
"/drug/shipments": { label: TEXT.menu.drugShipments, path: "/drug/shipments" },
"/subjects": { label: TEXT.menu.subjects, path: "/subjects" },
"/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" },
"/startup/training": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
"/knowledge/medical-consult": { label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult" },
"/knowledge/notes": { label: TEXT.menu.knowledgeNotes, path: "/knowledge/notes" },
};
let matchedModule = null;
for (const prefix in moduleMap) {
if (route.path.startsWith(prefix)) {
matchedModule = moduleMap[prefix];
break;
}
}
if (matchedModule) {
// 如果当前路径不是模块主路径,说明在子页面/详情页,需要把模块名展示出来
if (route.path !== matchedModule.path) {
items.push({
label: matchedModule.label,
path: matchedModule.path
});
}
}
}
// 4. 当前页面标题
const title = route.meta?.title as string | undefined;
if (title && title !== study.currentStudy?.name && title !== TEXT.menu.projectOverview && title !== (study.viewContext?.siteName || study.currentSite?.name)) {
// 检查 title 是否已经作为模块名添加过了
const isModuleTitle = items.some(item => item.label === title);
if (!isModuleTitle) {
items.push({
label: title,
path: route.path
});
}
}
return items;
});
const handleBreadcrumbCommand = (cmd: any) => {
if (cmd.type === 'study') {
study.setCurrentStudy(cmd.value);
router.push("/project/overview");
} else if (cmd.type === 'site') {
study.setCurrentSite(cmd.value);
// 刷新当前页面或根据业务逻辑处理
ElMessage.success(`已切换至: ${cmd.value?.name || '所有中心'}`);
}
};
watch(() => study.currentStudy, () => {
loadSites();
}, { immediate: true });
watch(() => route.path, () => {
study.setViewContext(null);
});
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value;
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
@@ -200,6 +359,10 @@ const onLogout = () => {
router.replace("/login");
};
onMounted(() => {
loadStudies();
});
const onCommand = (cmd: string) => {
if (cmd === "logout") {
onLogout();
@@ -216,12 +379,12 @@ const onCommand = (cmd: string) => {
}
.layout-aside {
background-color: #ffffff;
background-color: #2c3e50;
display: flex;
flex-direction: column;
box-shadow: none;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
z-index: 10;
border-right: 1px solid var(--ctms-border-color);
border-right: none;
transition: width 0.18s ease;
overflow: hidden;
}
@@ -249,7 +412,7 @@ const onCommand = (cmd: string) => {
height: 64px;
display: flex;
align-items: center;
padding: 0 24px;
padding: 0 16px;
background-color: transparent;
transition: padding 0.18s ease;
}
@@ -257,17 +420,17 @@ const onCommand = (cmd: string) => {
.logo-icon {
width: 28px;
height: 28px;
background: var(--ctms-primary);
border-radius: 10px;
background: #ffffff;
border-radius: 8px;
margin-right: 12px;
box-shadow: 0 6px 14px rgba(63, 93, 117, 0.18);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.logo-text {
color: var(--ctms-text-main);
color: #ffffff;
font-size: 18px;
font-weight: 800;
letter-spacing: 1px;
letter-spacing: 0.5px;
transition: opacity 0.18s ease, transform 0.18s ease;
}
@@ -280,63 +443,68 @@ const onCommand = (cmd: string) => {
}
.menu-divider {
padding: 16px 24px 8px;
font-size: 11px;
font-weight: 600;
color: var(--ctms-text-secondary);
padding: 16px 16px 8px;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 1.5px;
letter-spacing: 1px;
}
:deep(.el-menu-item) {
.layout-aside :deep(.el-menu-item) {
height: 44px;
line-height: 44px;
margin: 4px 12px;
border-radius: 12px;
color: var(--ctms-text-regular) !important;
margin: 4px 10px;
border-radius: 8px;
color: #f1f5f9 !important;
transition: var(--ctms-transition);
}
:deep(.el-menu-item:hover) {
color: var(--ctms-text-main) !important;
background-color: var(--ctms-bg-muted) !important;
.layout-aside :deep(.el-menu-item:hover) {
color: #ffffff !important;
background-color: rgba(255, 255, 255, 0.08) !important;
}
:deep(.el-menu-item.is-active) {
color: var(--ctms-primary) !important;
background-color: var(--ctms-primary-light) !important;
.layout-aside :deep(.el-menu-item.is-active) {
color: #ffffff !important;
background-color: rgba(255, 255, 255, 0.12) !important;
box-shadow: none;
}
:deep(.el-menu-item.is-active::before) {
.layout-aside :deep(.el-menu-item.is-active::before) {
content: "";
position: absolute;
left: 8px;
top: 12px;
bottom: 12px;
left: 0;
top: 10px;
bottom: 10px;
width: 3px;
background-color: var(--ctms-primary);
border-radius: 999px;
background-color: #3b82f6;
border-radius: 0 4px 4px 0;
}
:deep(.el-sub-menu__title) {
color: var(--ctms-text-regular) !important;
margin: 4px 12px;
border-radius: 12px;
.layout-aside :deep(.el-sub-menu__title) {
color: #f1f5f9 !important;
margin: 4px 10px;
border-radius: 8px;
height: 44px;
line-height: 44px;
}
:deep(.el-sub-menu.is-active .el-sub-menu__title) {
color: var(--ctms-text-main) !important;
.layout-aside :deep(.el-sub-menu__title:hover) {
color: #ffffff !important;
background-color: rgba(255, 255, 255, 0.08) !important;
}
:deep(.el-menu--inline) {
.layout-aside :deep(.el-sub-menu.is-active .el-sub-menu__title) {
color: #ffffff !important;
}
.layout-aside :deep(.el-menu--inline) {
background-color: transparent !important;
margin: 0 !important;
}
:deep(.el-menu--inline .el-menu-item) {
.layout-aside :deep(.el-menu--inline .el-menu-item) {
padding-left: 48px !important;
margin: 2px 12px;
}
@@ -350,7 +518,53 @@ const onCommand = (cmd: string) => {
box-shadow: none;
height: 64px !important;
z-index: 9;
border-bottom: 1px solid var(--ctms-border-color);
border-bottom: 1px solid #f1f5f9;
}
.header-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
color: var(--ctms-text-regular);
font-size: 14px;
margin-left: 20px;
user-select: none;
}
.breadcrumb-item-wrapper {
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s ease;
}
.breadcrumb-item-wrapper.is-link {
cursor: pointer;
}
.breadcrumb-item-wrapper.is-link:hover {
opacity: 0.7;
}
.is-link-text {
color: #3b82f6;
font-weight: 500;
}
.breadcrumb-item-text {
font-weight: 400;
}
.breadcrumb-dropdown-icon {
font-size: 12px;
color: #94a3b8;
margin-top: 2px;
}
.breadcrumb-separator {
margin: 0 4px;
color: #cbd5e1;
font-weight: 300;
}
.header-right {
@@ -422,7 +636,7 @@ const onCommand = (cmd: string) => {
}
.content-wrapper {
padding: 28px;
padding: 16px;
min-height: calc(100vh - 64px);
}
@@ -441,4 +655,13 @@ const onCommand = (cmd: string) => {
opacity: 0;
transform: translateX(15px);
}
.breadcrumb-dropdown-menu {
min-width: 200px;
}
.breadcrumb-dropdown-menu :deep(.el-dropdown-menu__item.active) {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
font-weight: 600;
}
</style>
+47 -8
View File
@@ -65,6 +65,8 @@ export const TEXT = {
input: "请输入",
select: "请选择",
keyword: "输入关键词",
startDate: "开始日期",
endDate: "结束日期",
},
labels: {
attachments: "附件",
@@ -86,6 +88,7 @@ export const TEXT = {
userInitialFallback: "用",
userFallback: "用户",
basicInfo: "基本信息",
allSites: "所有中心",
},
units: {
case: "例",
@@ -309,6 +312,9 @@ export const TEXT = {
alertOk: "项目运行状态良好,所有关键指标均在可控范围内。",
noMilestones: "暂无节点",
progressLabel: "总体进度: {rate}",
notificationsTitle: "通知",
notificationsSubtitle: "文件版本更新将推送在此处",
notificationsEmpty: "暂无通知",
},
financeContracts: {
title: "合同费用",
@@ -420,6 +426,8 @@ export const TEXT = {
attachmentOther: "其他",
attachmentOtherDesc: "上传其他支持性材料",
attachmentOtherEmpty: "暂无其他附件",
unpaid: "未打款",
unverified: "未核销",
uploadHint: "保存后可上传凭证/发票/其他附件",
},
drugShipments: {
@@ -456,7 +464,7 @@ export const TEXT = {
docType: "类型",
currentVersion: "当前版本",
status: "状态",
effectiveAt: "生效时间",
effectiveAt: "更新时间",
updatedAt: "更新",
actions: "操作",
},
@@ -496,7 +504,7 @@ export const TEXT = {
dueAt: "截止日期",
},
tabs: {
versions: "版本时间线",
versions: "版本管理",
distributions: "抄送",
audit: "审计",
},
@@ -592,6 +600,7 @@ export const TEXT = {
title: "参与者管理",
subtitle: "参与者、病史、访视、AE 综合管理",
subjectLabel: "参与者",
screeningNo: "筛选号",
empty: "暂无参与者记录",
},
subjectForm: {
@@ -771,6 +780,11 @@ export const TEXT = {
detailTitle: "项目详情",
basicInfo: "项目基本信息",
filesTitle: "项目通用文件",
lockConfirmPrompt: "请输入“确认锁定”以继续",
lockConfirmTitle: "二次确认",
lockConfirmPlaceholder: "请输入确认锁定",
lockConfirmMismatch: "输入内容不匹配",
lockConfirmMismatchWarning: "输入内容不匹配,已取消锁定",
},
adminProjectMembers: {
title: "项目成员配置(角色仅在当前项目内生效)",
@@ -862,6 +876,7 @@ export const TEXT = {
todayEmpty: "今天暂时没有需要紧急处理的业务事项",
overdueEmpty: "太棒了!目前没有任何逾期待处理的事项",
actionEmpty: "暂无需要您处理的事项",
newLostVisit: "新增失访",
quickEntryTitle: "快捷入口",
enterProject: "进入项目",
quickEmpty: "暂无需要你处理的事项",
@@ -943,13 +958,37 @@ export const TEXT = {
CANCELLED: "已取消",
},
aeSeriousness: {
SERIOUS: "严重",
NON_SERIOUS: "一般",
I: "I",
II: "II",
III: "III",
IV: "IV",
V: "V",
},
aeSeverity: {
MILD: "轻度",
MODERATE: "中度",
SEVERE: "重度",
I: "I",
II: "II",
III: "III",
IV: "IV",
V: "V",
MILD: "I",
MODERATE: "II",
SEVERE: "III",
},
aeCausality: {
: "肯定有关",
: "很可能有关",
: "可能有关",
: "可能无关",
: "无关",
: "待评估",
},
aeOutcome: {
: "痊愈",
: "好转",
: "持续",
: "恶化",
: "死亡",
访: "失访",
},
aeStatus: {
NEW: "新建",
@@ -1053,7 +1092,7 @@ export const TEXT = {
USER: "人员",
},
scopeType: {
GLOBAL: "全局",
GLOBAL: "通用",
SITE: "中心",
DERIVED: "派生",
},
-4
View File
@@ -505,10 +505,6 @@ router.beforeEach(async (to, _from, next) => {
next({ path: "/workbench" });
return;
}
if (to.path === "/workbench" && studyStore.currentStudy) {
next({ path: "/project/overview" });
return;
}
next();
});
+35
View File
@@ -5,12 +5,18 @@ import type { Study } from "../types/api";
const STUDY_KEY = "ctms_current_study";
const STUDY_ROLE_KEY = "ctms_current_study_role";
const SITE_KEY = "ctms_current_site";
export const useStudyStore = defineStore("study", () => {
const currentStudy = ref<Study | null>(null);
const currentStudyRole = ref<string | null>(null);
const currentSite = ref<any | null>(null);
const viewContext = ref<{ siteName?: string } | null>(null);
const setCurrentStudy = (study: Study) => {
currentStudy.value = study;
currentSite.value = null; // 切换项目时清空中心
localStorage.removeItem(SITE_KEY);
currentStudy.value = study;
const role = (study as any).role_in_study || (study as any).role || null;
currentStudyRole.value = role;
@@ -31,6 +37,15 @@ export const useStudyStore = defineStore("study", () => {
}
const savedRole = localStorage.getItem(STUDY_ROLE_KEY);
currentStudyRole.value = savedRole || null;
const savedSite = localStorage.getItem(SITE_KEY);
if (savedSite) {
try {
currentSite.value = JSON.parse(savedSite);
} catch {
currentSite.value = null;
}
}
};
const ensureDefaultStudy = async () => {
@@ -51,8 +66,11 @@ export const useStudyStore = defineStore("study", () => {
const clearCurrentStudy = () => {
currentStudy.value = null;
currentStudyRole.value = null;
currentSite.value = null;
viewContext.value = null;
localStorage.removeItem(STUDY_KEY);
localStorage.removeItem(STUDY_ROLE_KEY);
localStorage.removeItem(SITE_KEY);
};
const setCurrentStudyRole = (role: string | null) => {
@@ -64,11 +82,28 @@ export const useStudyStore = defineStore("study", () => {
}
};
const setCurrentSite = (site: any | null) => {
currentSite.value = site;
if (site) {
localStorage.setItem(SITE_KEY, JSON.stringify(site));
} else {
localStorage.removeItem(SITE_KEY);
}
};
const setViewContext = (ctx: { siteName?: string } | null) => {
viewContext.value = ctx;
};
return {
currentStudy,
currentStudyRole,
currentSite,
viewContext,
setCurrentStudy,
setCurrentStudyRole,
setCurrentSite,
setViewContext,
loadCurrentStudy,
ensureDefaultStudy,
clearCurrentStudy,
+8 -1
View File
@@ -319,6 +319,13 @@ body {
margin: 0;
}
.ctms-page-title,
.ctms-page-subtitle,
.page-title,
.page-subtitle {
display: none;
}
.ctms-page-actions {
display: flex;
@@ -456,4 +463,4 @@ body {
.text-sm {
font-size: 13px;
}
}
+11
View File
@@ -0,0 +1,11 @@
export interface NotificationItem {
id: string;
document_id: string;
version_id: string;
document_title: string;
document_no?: string | null;
version_no: string;
change_summary?: string | null;
effective_at?: string | null;
created_at: string;
}
+9
View File
@@ -0,0 +1,9 @@
export interface VisitLostItem {
visit_id: string;
subject_id: string;
subject_no: string;
site_id?: string | null;
visit_code: string;
status: string;
updated_at: string;
}
File diff suppressed because it is too large Load Diff
+122 -1
View File
@@ -67,6 +67,33 @@
class="home-alert"
/>
<!-- 通知 -->
<el-card class="notifications-card">
<div class="notifications-header">
<span class="notifications-title">{{ TEXT.modules.projectOverview.notificationsTitle }}</span>
<span class="notifications-subtitle">{{ TEXT.modules.projectOverview.notificationsSubtitle }}</span>
</div>
<el-skeleton v-if="loading.notifications" :rows="3" animated />
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
<div v-else class="notification-list">
<div class="notification-item" v-for="item in notifications" :key="item.id">
<div class="notification-main">
<div class="notification-title">
<span class="notification-doc">{{ item.document_title }}</span>
<span class="notification-version">V{{ item.version_no }}</span>
</div>
<div class="notification-meta">
<span class="notification-time">{{ formatDate(item.effective_at || item.created_at) }}</span>
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
</div>
</div>
<el-button link type="primary" @click="openDocument(item.document_id)">
{{ TEXT.common.actions.view }}
</el-button>
</div>
</div>
</el-card>
<!-- 快捷模块 -->
<div class="quick-nav-section">
<QuickActions />
@@ -83,24 +110,30 @@ import { computed, onMounted, ref, watch } from "vue";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchFinanceSummary, fetchOverdueAesCount, fetchProgress } from "../api/dashboard";
import { listNotifications } from "../api/notifications";
import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue";
import { List, Warning, Money } from "@element-plus/icons-vue";
import StateEmpty from "../components/StateEmpty.vue";
import { displayEnum } from "../utils/display";
import { TEXT } from "../locales";
import type { NotificationItem } from "../types/notifications";
import { useRouter } from "vue-router";
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const progress = ref<any>(null);
const financeSummary = ref<any>(null);
const overdueAes = ref<number>(0);
const notifications = ref<NotificationItem[]>([]);
const loading = ref({
progress: false,
finance: false,
overdueAes: false,
notifications: false,
});
const milestoneCompletionRate = computed(() => {
@@ -123,20 +156,24 @@ const loadData = async () => {
loading.value.progress = true;
loading.value.finance = true;
loading.value.overdueAes = true;
loading.value.notifications = true;
try {
const [pRes, fRes, aesRes] = await Promise.allSettled([
const [pRes, fRes, aesRes, nRes] = await Promise.allSettled([
fetchProgress(studyId),
fetchFinanceSummary(studyId),
fetchOverdueAesCount(studyId),
listNotifications(studyId, { limit: 5 }),
]);
if (pRes.status === "fulfilled") progress.value = pRes.value.data;
if (fRes.status === "fulfilled") financeSummary.value = fRes.value.data;
if (aesRes.status === "fulfilled") overdueAes.value = aesRes.value.data?.total ?? 0;
if (nRes.status === "fulfilled") notifications.value = nRes.value.data || [];
} finally {
loading.value.progress = false;
loading.value.finance = false;
loading.value.overdueAes = false;
loading.value.notifications = false;
}
};
@@ -144,6 +181,16 @@ const resetData = () => {
progress.value = null;
financeSummary.value = null;
overdueAes.value = 0;
notifications.value = [];
};
const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
const openDocument = (documentId: string) => {
router.push(`/documents/${documentId}`);
};
onMounted(() => {
@@ -217,6 +264,80 @@ watch(
border-radius: var(--ctms-radius);
}
.notifications-card {
border-radius: 16px;
}
.notifications-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 12px;
}
.notifications-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
}
.notifications-subtitle {
font-size: 12px;
color: var(--ctms-text-secondary);
}
.notification-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.notification-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-radius: 12px;
background: var(--ctms-bg-muted);
}
.notification-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.notification-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: var(--ctms-text-main);
}
.notification-version {
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
background: var(--ctms-primary-light);
color: var(--ctms-primary);
}
.notification-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.notification-summary {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quick-nav-section {
margin-top: 8px;
}
+36 -21
View File
@@ -1,17 +1,17 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>{{ TEXT.modules.adminUserApproval.title }}</h3>
<p>{{ TEXT.modules.adminUserApproval.subtitle }}</p>
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<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">
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select>
<div class="filter-spacer"></div>
</div>
<el-select v-model="status" size="small" style="width: 180px" @change="loadUsers">
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select>
</div>
<el-table :data="users" v-loading="loading" style="width: 100%">
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
@@ -120,19 +120,34 @@ onMounted(() => {
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
flex-direction: column;
}
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header h3 {
margin: 0;
.filter-label-tag {
border: none;
background: transparent;
color: var(--ctms-text-secondary);
padding: 0;
font-weight: 500;
}
.header p {
margin: 4px 0 0;
color: #6c7a89;
.filter-spacer {
flex: 1;
}
</style>
+73 -81
View File
@@ -1,59 +1,48 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>{{ TEXT.modules.adminAuditLogs.title }}</h3>
</div>
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<el-form :inline="true" :model="filters" class="filter-form">
<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" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="filter-select-comp">
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" style="width: 120px">
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-date-picker
v-model="filters.range"
type="daterange"
unlink-panels
:start-placeholder="TEXT.modules.adminAuditLogs.rangeStart"
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="date-range-picker-comp"
@change="loadLogs"
/>
</el-form-item>
<div class="filter-spacer"></div>
<div class="ctms-action-group">
<el-button v-if="isAdmin" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('system')">
{{ TEXT.modules.adminAuditLogs.exportSystem }}
</el-button>
<el-button v-if="canProjectExport" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('project')">
{{ TEXT.modules.adminAuditLogs.exportProject }}
</el-button>
</div>
</el-form>
</div>
<div class="ctms-table-toolbar">
<div class="ctms-filter-group">
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="ctms-filter-item w-40">
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="ctms-filter-item w-40">
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select>
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" class="ctms-filter-item w-32">
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
</el-select>
<el-date-picker
v-model="filters.range"
type="daterange"
unlink-panels
:start-placeholder="TEXT.modules.adminAuditLogs.rangeStart"
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="ctms-filter-item date-range-picker"
@change="loadLogs"
/>
</div>
<div class="ctms-action-group">
<el-button
v-if="isAdmin"
type="warning"
plain
size="small"
:loading="exportLoading"
@click="confirmExport('system')"
>
{{ TEXT.modules.adminAuditLogs.exportSystem }}
</el-button>
<el-button
v-if="canProjectExport"
type="warning"
plain
size="small"
:loading="exportLoading"
@click="confirmExport('project')"
>
{{ TEXT.modules.adminAuditLogs.exportProject }}
</el-button>
</div>
</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">
@@ -334,53 +323,56 @@ onMounted(async () => {
<style scoped>
.page {
padding: 24px;
}
.header {
display: flex;
align-items: center;
margin-bottom: 24px;
flex-direction: column;
}
.header h3 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0;
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.ctms-table-toolbar {
display: flex;
justify-content: space-between;
.filter-container {
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.ctms-filter-group {
.filter-form {
display: flex;
width: 100%;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.ctms-filter-item {
width: auto;
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select-comp {
width: 150px;
}
.date-range-picker-comp {
width: 280px !important;
}
.filter-spacer {
flex: 1;
}
.w-40 { width: 160px; }
.w-32 { width: 130px; }
.w-64 { width: 260px; }
.diff-container {
font-size: 13px;
color: #4b5563;
line-height: 1.5;
}
.diff-line {
margin-bottom: 2px;
word-break: break-all;
}
.date-range-picker {
width: auto;
min-width: 320px;
}
.pagination {
margin-top: 24px;
text-align: right;
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
+15 -3
View File
@@ -300,14 +300,26 @@ const handleLock = async () => {
type: "warning",
}
);
const { value } = await ElMessageBox.prompt(TEXT.modules.adminProjects.lockConfirmPrompt, TEXT.modules.adminProjects.lockConfirmTitle, {
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
inputPlaceholder: TEXT.modules.adminProjects.lockConfirmPlaceholder,
inputValidator: (input: string) => input === "确认锁定" || TEXT.modules.adminProjects.lockConfirmMismatch,
type: "warning",
});
if (value !== "确认锁定") {
ElMessage.warning(TEXT.modules.adminProjects.lockConfirmMismatchWarning);
return;
}
const { data } = await lockStudy(project.value.id);
project.value = data as Study;
syncForm(project.value);
ElMessage.success("项目已锁定");
} catch (err) {
if (err !== "cancel") {
ElMessage.error("锁定项目失败");
if (err !== "cancel" && err !== "close") {
const detail = (err as any)?.response?.data?.detail || "锁定项目失败";
ElMessage.error(detail);
}
}
};
+8
View File
@@ -4,6 +4,9 @@
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.projectCode" prop="code">
<el-input v-model="form.code" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectCode" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" />
</el-form-item>
@@ -64,6 +67,7 @@ const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
name: "",
code: "",
protocol_no: "",
phase: "",
status: "DRAFT",
@@ -80,6 +84,7 @@ const rules = reactive<FormRules>({
const resetForm = () => {
form.name = "";
form.code = "";
form.protocol_no = "";
form.phase = "";
form.status = "DRAFT";
@@ -96,6 +101,7 @@ watch(
resetForm();
if (props.project) {
form.name = props.project.name;
form.code = props.project.code || "";
form.protocol_no = props.project.protocol_no || "";
form.phase = props.project.phase || "";
form.status = props.project.status || "DRAFT";
@@ -116,6 +122,7 @@ const onSubmit = async () => {
if (props.project) {
await updateStudy(props.project.id, {
name: form.name,
code: form.code,
protocol_no: form.protocol_no,
phase: form.phase,
status: form.status,
@@ -128,6 +135,7 @@ const onSubmit = async () => {
} else {
await createStudy({
name: form.name,
code: form.code,
protocol_no: form.protocol_no,
phase: form.phase,
status: form.status,
+32 -14
View File
@@ -1,12 +1,13 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>{{ TEXT.modules.adminProjectMembers.title }}</h3>
<div class="sub" v-if="project">{{ TEXT.modules.adminProjectMembers.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
<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-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
</div>
<el-table :data="memberRows" v-loading="loading" stripe>
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
@@ -394,18 +395,35 @@ onMounted(async () => {
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
flex-direction: column;
}
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sub {
color: #666;
margin-top: 4px;
.filter-spacer {
flex: 1;
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
.hint {
color: #999;
margin: 0 8px;
+54 -36
View File
@@ -1,11 +1,13 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>{{ TEXT.modules.adminProjects.title }}</h3>
<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-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}</el-button>
</div>
<el-table :data="projects" v-loading="loading" stripe>
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="300">
@@ -13,6 +15,11 @@
<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>
@@ -24,11 +31,6 @@
<el-tag v-else type="success" effect="plain" round>未锁定</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" min-width="180" align="center">
<template #default="scope">
<span class="text-gray-500">{{ displayDateTime(scope.row.created_at) }}</span>
</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">
@@ -69,7 +71,6 @@ import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
import type { Study } from "../../types/api";
import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study";
import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
const projects = ref<Study[]>([]);
@@ -167,13 +168,25 @@ const handleLockToggle = async (study: Study) => {
type: "warning",
}
);
const { value } = await ElMessageBox.prompt(TEXT.modules.adminProjects.lockConfirmPrompt, TEXT.modules.adminProjects.lockConfirmTitle, {
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
inputPlaceholder: TEXT.modules.adminProjects.lockConfirmPlaceholder,
inputValidator: (input: string) => input === "确认锁定" || TEXT.modules.adminProjects.lockConfirmMismatch,
type: "warning",
});
if (value !== "确认锁定") {
ElMessage.warning(TEXT.modules.adminProjects.lockConfirmMismatchWarning);
return;
}
await lockStudy(study.id);
ElMessage.success("项目已锁定");
await loadProjects();
} catch (err) {
if (err !== "cancel") {
ElMessage.error("锁定项目失败");
if (err !== "cancel" && err !== "close") {
const detail = (err as any)?.response?.data?.detail || "锁定项目失败";
ElMessage.error(detail);
}
}
};
@@ -204,27 +217,33 @@ onMounted(() => {
<style scoped>
.page {
padding: 24px;
}
.header {
display: flex;
flex-direction: column;
}
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.header h3 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0;
.filter-spacer {
flex: 1;
}
.sub {
color: #6b7280;
margin-top: 8px;
font-size: 14px;
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
/* 表格样式美化 */
@@ -238,19 +257,18 @@ onMounted(() => {
:deep(.el-table th.el-table__cell) {
background-color: #f9fafb;
font-weight: 600;
height: 50px;
height: 44px;
}
/* 增加单元格间距 */
:deep(.el-table .el-table__cell) {
padding: 16px 0;
padding: 8px 0;
}
/* 操作按钮美化 */
.action-btn {
font-size: 20px; /* 图标变更大 */
padding: 6px;
margin: 0 4px;
font-size: 18px; /* 图标变更大 */
padding: 4px;
margin: 0 2px;
transition: all 0.3s;
}
@@ -261,7 +279,7 @@ onMounted(() => {
}
.enter-btn {
font-size: 20px;
font-size: 18px;
font-weight: bold;
}
</style>
+32 -15
View File
@@ -1,12 +1,13 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>{{ TEXT.modules.adminSites.title }}</h3>
<div class="sub" v-if="project">{{ TEXT.modules.adminSites.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
<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-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}</el-button>
</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" />
@@ -266,17 +267,33 @@ onMounted(async () => {
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
flex-direction: column;
}
.sub {
color: #666;
margin-top: 4px;
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
}
.filter-spacer {
flex: 1;
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
</style>
+78 -25
View File
@@ -1,12 +1,32 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>{{ TEXT.modules.adminUsers.title }}</h3>
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<div class="filter-form">
<el-form-item label="" class="filter-item-form">
<el-input
v-model="searchKeyword"
:placeholder="TEXT.common.placeholders.keyword"
clearable
class="filter-input-comp"
/>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="loadUsers">
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item class="filter-item-form">
<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>
</div>
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
</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" />
@@ -50,16 +70,15 @@
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
background
layout="prev, pager, next, total"
:page-size="pageSize"
:total="total"
v-model:current-page="page"
@current-change="loadUsers"
/>
</div>
<el-pagination
background
layout="prev, pager, next, total"
:page-size="pageSize"
:total="total"
v-model:current-page="page"
@current-change="loadUsers"
class="pagination"
/>
</el-card>
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
@@ -88,6 +107,8 @@ const loading = ref(false);
const activeAdminCount = ref(0);
const formVisible = ref(false);
const resetVisible = ref(false);
const searchKeyword = ref("");
const statusFilter = ref("");
const editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null);
const auth = useAuthStore();
@@ -127,7 +148,12 @@ const loadUsers = async () => {
loading.value = true;
try {
const [{ data }, { data: adminData }] = await Promise.all([
fetchUsers({ skip: (page.value - 1) * pageSize.value, limit: pageSize.value }),
fetchUsers({
skip: (page.value - 1) * pageSize.value,
limit: pageSize.value,
keyword: searchKeyword.value || undefined,
status: statusFilter.value || undefined,
}),
fetchUsers({ skip: 0, limit: 10000 }),
]);
const items = (data as any).items || [];
@@ -252,20 +278,47 @@ onMounted(() => {
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
flex-direction: column;
}
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sub {
color: #666;
margin-top: 4px;
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-input-comp {
width: 200px;
}
.filter-spacer {
flex: 1;
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
.pagination {
margin-top: 12px;
text-align: right;
display: flex;
justify-content: flex-end;
}
</style>
+201 -306
View File
@@ -27,7 +27,12 @@
<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">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</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>
@@ -35,7 +40,7 @@
<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">{{ TEXT.modules.fileVersionManagement.labels.versionTimeline }}</div>
<div class="ctms-section-title">&nbsp;</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="openUpload" v-if="canAction('create_version')">
<el-icon class="el-icon--left"><Upload /></el-icon>
@@ -50,9 +55,9 @@
<span class="font-medium">V{{ row.version_no }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.status" width="120">
<el-table-column label="版本日期" width="140">
<template #default="{ row }">
<el-tag :type="getVersionStatusType(row.status)" effect="light" size="small">{{ displayEnum(TEXT.enums.documentVersionStatus, row.status) }}</el-tag>
<span class="text-secondary">{{ formatDateOnly(row.effective_at || row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column prop="change_summary" :label="TEXT.modules.fileVersionManagement.fields.changeSummary" min-width="200" show-overflow-tooltip />
@@ -61,37 +66,29 @@
<span>{{ displayCreator(row.created_by) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.createdAt" width="180">
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.parentVersion" width="140">
<template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
<span class="text-secondary">{{ displayParentVersion(row.parent_version_id) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.approvalSummary" min-width="220">
<el-table-column prop="effective_at" :label="TEXT.modules.fileVersionManagement.columns.effectiveAt" width="180">
<template #default="{ row }">
<div class="text-xs text-secondary lh-sm">
<div v-if="row.submitted_at">{{ TEXT.modules.fileVersionManagement.labels.submitAt }}{{ formatDate(row.submitted_at) }}</div>
<div v-if="row.approved_at" class="mt-1">
{{ TEXT.modules.fileVersionManagement.labels.approveAt }}{{ formatDate(row.approved_at) }}
</div>
</div>
<span class="text-secondary">{{ formatDate(row.effective_at || row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="280" align="center">
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="180" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canAction('view')">
{{ TEXT.modules.fileVersionManagement.actions.download }}
</el-button>
<el-button link type="primary" size="small" @click="openSubmit(row)" v-if="canAction('submit') && row.status === 'DRAFT'">
{{ TEXT.modules.fileVersionManagement.actions.submit }}
</el-button>
<el-button link type="success" size="small" @click="approve(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
{{ TEXT.modules.fileVersionManagement.actions.approve }}
</el-button>
<el-button link type="danger" size="small" @click="reject(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
{{ TEXT.modules.fileVersionManagement.actions.reject }}
</el-button>
<el-button link type="warning" size="small" @click="makeEffective(row)" v-if="canAction('approve') && row.status === 'APPROVED'">
{{ TEXT.modules.fileVersionManagement.actions.makeEffective }}
<el-button
v-if="isAdmin"
link
type="danger"
size="small"
@click="confirmDeleteVersion(row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
@@ -101,9 +98,9 @@
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.distributions" name="distributions">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.fileVersionManagement.labels.distributionList }}</div>
<div class="ctms-section-title">&nbsp;</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="openDistribute" v-if="canAction('distribute')">
<el-button type="primary" @click="openDistribute">
<el-icon class="el-icon--left"><Share /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.distribute }}
</el-button>
@@ -121,48 +118,7 @@
<span class="font-medium">{{ displayTarget(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackStats" min-width="200">
<template #default="{ row }">
<span>{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}: {{ row.stats?.received || 0 }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.overdue" width="100">
<template #default="{ row }">
<el-tag v-if="row.stats?.overdue" type="danger" effect="dark" size="small">{{ TEXT.modules.fileVersionManagement.labels.overdue }}</el-tag>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.dueAt" width="160">
<template #default="{ row }">
<span :class="{ 'text-danger': row.stats?.overdue }">{{ formatDate(row.due_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackAction" width="140" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="ack(row, 'RECEIVED')" v-if="canAction('ack')">
{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.audit" name="audit">
<el-card class="ctms-table-card">
<el-table :data="auditLogs" v-loading="auditLoading" class="ctms-table">
<el-table-column prop="action" :label="TEXT.modules.fileVersionManagement.labels.auditAction" width="200">
<template #default="{ row }">
<span class="font-medium">{{ row.action }}</span>
</template>
</el-table-column>
<el-table-column prop="detail" :label="TEXT.modules.fileVersionManagement.labels.auditDetail" min-width="300" show-overflow-tooltip />
<el-table-column prop="operator_role" :label="TEXT.common.labels.role" width="120">
<template #default="{ row }">
<el-tag effect="plain" size="small">{{ row.operator_role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.auditTime" width="180">
<el-table-column label="抄送时间" width="180">
<template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
</template>
@@ -170,6 +126,7 @@
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog">
@@ -177,6 +134,15 @@
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.versionNo" prop="version_no">
<el-input v-model="uploadForm.version_no" :placeholder="TEXT.modules.fileVersionManagement.placeholders.versionNo" />
</el-form-item>
<el-form-item label="版本日期" prop="version_date">
<el-date-picker
v-model="uploadForm.version_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.changeSummary">
<el-input v-model="uploadForm.change_summary" type="textarea" :rows="3" />
</el-form-item>
@@ -204,49 +170,44 @@
<el-form :model="distributeForm" label-width="100px" class="ctms-form">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.targetList">
<div class="target-list w-full">
<div class="target-row" v-for="(item, index) in distributeForm.targets" :key="item.uid">
<el-select
v-model="item.target_type"
:placeholder="TEXT.common.placeholders.select"
style="width: 120px"
@change="onTargetTypeChange(index)"
>
<div class="target-toolbar">
<el-select v-model="distributeForm.target_type" class="target-type-preset" :placeholder="TEXT.common.labels.all" @change="onTargetTypeChange">
<el-option :label="TEXT.common.labels.all" value="ALL" />
<el-option :label="TEXT.enums.distributionTargetType.ROLE" value="ROLE" />
<el-option :label="TEXT.enums.distributionTargetType.USER" value="USER" />
</el-select>
<el-select
v-if="item.target_type === 'ROLE'"
v-model="item.target_id"
filterable
clearable
:placeholder="TEXT.common.placeholders.select"
class="target-select"
>
<el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
</el-select>
<el-select
v-else
v-model="item.target_id"
filterable
clearable
:placeholder="TEXT.common.placeholders.select"
class="target-select"
>
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" />
</el-select>
<el-button link type="danger" @click="removeTarget(index)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button type="primary" plain @click="addTarget" class="w-full dashed-button">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.addTarget }}
</el-button>
<div class="target-row">
<template v-if="distributeForm.target_type === 'ROLE'">
<el-select
v-model="distributeForm.role_ids"
multiple
filterable
clearable
:placeholder="TEXT.common.placeholders.select"
class="target-select"
>
<el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
</el-select>
</template>
<template v-else-if="distributeForm.target_type === 'USER'">
<el-select
v-model="distributeForm.user_ids"
multiple
filterable
clearable
:placeholder="TEXT.common.placeholders.select"
class="target-select"
>
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" />
</el-select>
</template>
<div v-else class="target-all">
<el-tag effect="plain" type="info">{{ TEXT.common.labels.all }}</el-tag>
</div>
</div>
</div>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.dueAt">
<el-date-picker v-model="distributeForm.due_at" type="datetime" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="distributeVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
@@ -254,79 +215,40 @@
</template>
</el-dialog>
<el-dialog v-model="submitVisible" :title="TEXT.modules.fileVersionManagement.messages.submitTitle" width="480px" destroy-on-close class="ctms-dialog">
<el-form :model="submitForm" label-width="100px" class="ctms-form">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.workflowTemplate">
<el-select
v-model="submitForm.template_id"
filterable
clearable
:placeholder="TEXT.modules.fileVersionManagement.placeholders.workflowTemplate"
style="width: 100%"
>
<el-option v-for="item in workflowTemplates" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitLoading" @click="confirmSubmit">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { ArrowLeft, Upload, Plus, Download, Check, Close, Link, Share, Delete } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { ArrowLeft, Upload, Download, Share } from "@element-plus/icons-vue";
import {
approveVersion,
createAcknowledgement,
createDistributions,
deleteDocumentVersion,
downloadDocumentVersion,
fetchDocumentDetail,
listDistributions,
listWorkflowTemplates,
makeVersionEffective,
rejectVersion,
submitVersion,
uploadDocumentVersion,
} from "../../api/documents";
import { fetchAuditLogs } from "../../api/auditLogs";
import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import type { Distribution, DocumentDetail, DocumentVersion, WorkflowTemplate } from "../../types/documents";
import type { Distribution, DocumentDetail, DocumentVersion } from "../../types/documents";
import type { Site, StudyMember } from "../../types/api";
import type { UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { TEXT } from "../../locales";
import { displayEnum, displayText, getUserDisplayName } from "../../utils/display";
const getVersionStatusType = (status: string) => {
switch (status) {
case "APPROVED":
return "success";
case "DRAFT":
return "info";
case "SUBMITTED":
return "warning";
case "REJECTED":
return "danger";
default:
return "";
}
};
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const documentId = computed(() => route.params.id as string);
const loading = ref(false);
const versionLoading = ref(false);
const distributionLoading = ref(false);
const auditLoading = ref(false);
const detail = reactive<DocumentDetail>({
id: "",
trial_id: "",
@@ -344,11 +266,9 @@ const detail = reactive<DocumentDetail>({
const activeTab = ref("versions");
const distributions = ref<Distribution[]>([]);
const auditLogs = ref<any[]>([]);
const users = ref<UserInfo[]>([]);
const sites = ref<Site[]>([]);
const members = ref<StudyMember[]>([]);
const workflowTemplates = ref<WorkflowTemplate[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
@@ -358,29 +278,57 @@ const siteActiveMap = computed(() => {
return map;
});
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const userMap = computed(() =>
users.value.reduce<Record<string, string>>((acc, user) => {
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
const memberUserMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, member) => {
const user = (member as any).user;
const name = getUserDisplayName(user) || user?.email || user?.username || member.user_id;
acc[member.user_id] = name;
return acc;
}, {})
);
const userMap = computed(() => {
const map = { ...memberUserMap.value };
users.value.forEach((user) => {
map[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
});
return map;
});
const displayCreator = (userId?: string | null) => {
if (!userId) return TEXT.common.fallback;
return userMap.value[userId] || userId;
};
const displayParentVersion = (parentId?: string | null) => {
if (!parentId) return TEXT.common.fallback;
const hit = detail.version_timeline?.find((item: any) => item.id === parentId);
return hit?.version_no ? `V${hit.version_no}` : TEXT.common.fallback;
};
const displaySite = (siteId?: string | null) => {
if (!siteId) return TEXT.enums.scopeType.GLOBAL;
return sites.value.find((s) => s.id === siteId)?.name || siteId;
};
const currentEffectiveVersion = computed(() => {
if (!detail.current_effective_version_id) return null;
return detail.version_timeline?.find((item: any) => item.id === detail.current_effective_version_id) || null;
});
const distributionVersionId = computed(() => {
if (detail.current_effective_version_id) return detail.current_effective_version_id;
return detail.version_timeline?.[0]?.id || null;
});
const uploadVisible = ref(false);
const uploading = ref(false);
const uploadFormRef = ref<FormInstance>();
const uploadForm = reactive({
version_no: "",
version_date: "",
change_summary: "",
parent_version_id: "",
});
@@ -388,19 +336,12 @@ const uploadFile = ref<File | null>(null);
const distributeVisible = ref(false);
const distributing = ref(false);
const targetSeed = ref(0);
const distributeForm = reactive({
targets: [{ uid: 0, target_type: "ROLE", target_id: "" }],
due_at: "",
target_type: "ALL" as "ALL" | "ROLE" | "USER",
role_ids: [] as string[],
user_ids: [] as string[],
});
const submitVisible = ref(false);
const submitLoading = ref(false);
const submitForm = reactive({
template_id: "",
});
const submitTarget = ref<DocumentVersion | null>(null);
const uploadRules: FormRules = {
version_no: [
{ required: true, message: TEXT.common.messages.required, trigger: "blur" },
@@ -412,6 +353,7 @@ const uploadRules: FormRules = {
trigger: "blur",
},
],
version_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
};
const roleOptions = computed(() => {
@@ -447,12 +389,6 @@ const displayTarget = (row: Distribution) => {
return row.target_id;
};
const createTarget = () => ({
uid: ++targetSeed.value,
target_type: "ROLE",
target_id: "",
});
const canAction = (action: string) => {
if (isReadOnly.value && action !== "view") return false;
const list = detail.can_actions || [];
@@ -487,6 +423,7 @@ const loadSites = async () => {
};
const loadUsers = async () => {
if (!isAdmin.value) return;
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
@@ -521,13 +458,13 @@ const loadDetail = async () => {
};
const loadDistributions = async () => {
if (!detail.current_effective_version_id) {
if (!distributionVersionId.value) {
distributions.value = [];
return;
}
distributionLoading.value = true;
try {
const { data } = await listDistributions(detail.current_effective_version_id);
const { data } = await listDistributions(distributionVersionId.value);
distributions.value = data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -536,44 +473,15 @@ const loadDistributions = async () => {
}
};
const loadAuditLogs = async () => {
if (!detail.trial_id) return;
auditLoading.value = true;
try {
const { data } = await fetchAuditLogs(detail.trial_id, {
entity_type: "DOCUMENT",
entity_id: documentId.value,
limit: 20,
});
auditLogs.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
auditLoading.value = false;
}
};
const loadWorkflowTemplates = async () => {
try {
const params = detail.trial_id ? { trial_id: detail.trial_id } : undefined;
const { data } = await listWorkflowTemplates(params);
workflowTemplates.value = data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const handleTabChange = async (name: string) => {
if (name === "distributions") {
await loadDistributions();
}
if (name === "audit") {
await loadAuditLogs();
}
};
const openUpload = () => {
uploadForm.version_no = "";
uploadForm.version_date = "";
uploadForm.change_summary = "";
uploadForm.parent_version_id = "";
uploadFile.value = null;
@@ -600,6 +508,9 @@ const submitUpload = async () => {
try {
const formData = new FormData();
formData.append("version_no", uploadForm.version_no);
if (uploadForm.version_date) {
formData.append("version_date", uploadForm.version_date);
}
if (uploadForm.change_summary) {
formData.append("change_summary", uploadForm.change_summary);
}
@@ -619,73 +530,6 @@ const submitUpload = async () => {
});
};
const openSubmit = async (version: DocumentVersion) => {
submitTarget.value = version;
submitForm.template_id = "";
submitVisible.value = true;
if (!workflowTemplates.value.length) {
await loadWorkflowTemplates();
}
};
const confirmSubmit = async () => {
if (!submitTarget.value) return;
if (!submitForm.template_id) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.workflowTemplateRequired);
return;
}
submitLoading.value = true;
try {
await submitVersion(submitTarget.value.id, { template_id: submitForm.template_id, comment: "" });
submitVisible.value = false;
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.submitSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
submitLoading.value = false;
}
};
const approve = async (version: DocumentVersion) => {
versionLoading.value = true;
try {
await approveVersion(version.id, { comment: "" });
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.approveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
versionLoading.value = false;
}
};
const reject = async (version: DocumentVersion) => {
versionLoading.value = true;
try {
await rejectVersion(version.id, { comment: "" });
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.rejectSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
versionLoading.value = false;
}
};
const makeEffective = async (version: DocumentVersion) => {
versionLoading.value = true;
try {
await makeVersionEffective(version.id);
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.effectiveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
versionLoading.value = false;
}
};
const getFilename = (header?: string | null) => {
if (!header) return null;
const match = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(header);
@@ -714,47 +558,72 @@ const downloadVersion = async (version: DocumentVersion) => {
}
};
const confirmDeleteVersion = async (version: DocumentVersion) => {
if (!version?.id) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteDocumentVersion(version.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
await loadDetail();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const openDistribute = () => {
distributeForm.targets = [createTarget()];
distributeForm.due_at = "";
distributeForm.target_type = "ALL";
distributeForm.role_ids = [];
distributeForm.user_ids = [];
distributeVisible.value = true;
if (!members.value.length) {
loadMembers();
}
};
const addTarget = () => {
distributeForm.targets.push(createTarget());
};
const removeTarget = (index: number) => {
distributeForm.targets.splice(index, 1);
};
const onTargetTypeChange = (index: number) => {
const target = distributeForm.targets[index];
if (!target) return;
target.target_id = "";
const onTargetTypeChange = () => {
distributeForm.role_ids = [];
distributeForm.user_ids = [];
};
const submitDistribute = async () => {
if (!detail.current_effective_version_id) {
if (!distributionVersionId.value) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.noEffective);
return;
}
if (distributeForm.targets.some((t) => !t.target_id)) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
return;
}
distributing.value = true;
try {
const targets = distributeForm.targets.map((t) => ({
target_type: t.target_type,
target_id: t.target_id,
}));
await createDistributions(detail.current_effective_version_id, {
let targets: { target_type: string; target_id: string }[] = [];
if (distributeForm.target_type === "ALL") {
targets = memberOptions.value.map((m) => ({
target_type: "USER",
target_id: m.value,
}));
} else if (distributeForm.target_type === "ROLE") {
if (!distributeForm.role_ids.length) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
return;
}
targets = distributeForm.role_ids.map((role) => ({
target_type: "ROLE",
target_id: role,
}));
} else if (distributeForm.target_type === "USER") {
if (!distributeForm.user_ids.length) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
return;
}
targets = distributeForm.user_ids.map((user) => ({
target_type: "USER",
target_id: user,
}));
}
if (!targets.length) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
return;
}
await createDistributions(distributionVersionId.value, {
targets,
due_at: distributeForm.due_at || undefined,
});
distributeVisible.value = false;
await loadDistributions();
@@ -766,16 +635,6 @@ const submitDistribute = async () => {
}
};
const ack = async (distribution: Distribution, ackType: string) => {
try {
await createAcknowledgement(distribution.id, { ack_type: ackType });
await loadDistributions();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.ackSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
}
};
const goBack = () => {
if (detail.trial_id) {
router.push(`/trial/${detail.trial_id}/documents`);
@@ -789,6 +648,12 @@ const formatDate = (value?: string | null) => {
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
const formatDateOnly = (value?: string | null) => {
const formatted = formatDate(value);
if (formatted === TEXT.common.fallback) return formatted;
return formatted.split(" ")[0];
};
onMounted(async () => {
await loadDetail();
await loadDistributions();
@@ -846,6 +711,36 @@ watch(
border-style: dashed;
}
.target-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.target-type-preset {
width: 120px;
}
.target-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.target-select {
flex: 1;
min-width: 180px;
}
.target-all {
flex: 1;
display: flex;
align-items: center;
min-width: 180px;
}
.flex {
display: flex;
}
+73 -58
View File
@@ -1,46 +1,34 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
</div>
<div class="ctms-page-actions">
<el-button type="primary" @click="openCreate">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.fileVersionManagement.newDocument }}
</el-button>
</div>
</div>
<el-card class="ctms-filter-card">
<div class="ctms-filter-row">
<div class="ctms-filter-item">
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.scope }}</span>
<el-select v-model="filters.scope_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.site }}</span>
<el-select v-model="filters.site_id" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable filterable>
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.docType }}</span>
<el-select v-model="filters.doc_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<div class="ctms-filter-spacer"></div>
<div class="ctms-filter-actions">
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<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">
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.site_id" :placeholder="TEXT.modules.fileVersionManagement.filters.site" clearable filterable class="filter-select-comp">
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.doc_type" :placeholder="TEXT.modules.fileVersionManagement.filters.docType" clearable class="filter-select-comp">
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item class="filter-item-form">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
<div class="filter-spacer"></div>
<el-button type="primary" @click="openCreate" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.fileVersionManagement.newDocument }}
</el-button>
</el-form>
</div>
</el-card>
<el-card class="ctms-table-card">
<el-table
:data="sortedItems"
v-loading="loading"
@@ -86,7 +74,7 @@
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" align="center">
<template #default="{ row }">
<el-button link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(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>
@@ -137,6 +125,7 @@ import { Plus } from "@element-plus/icons-vue";
import { fetchDocuments, createDocument, deleteDocument } from "../../api/documents";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import type { DocumentSummary } from "../../types/documents";
import type { Site } from "../../types/api";
import { displayEnum, displayText } from "../../utils/display";
@@ -145,6 +134,7 @@ import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const auth = useAuthStore();
const loading = ref(false);
const items = ref<DocumentSummary[]>([]);
const sites = ref<Site[]>([]);
@@ -178,11 +168,23 @@ const siteActiveMap = computed(() => {
});
return map;
});
const isAdmin = computed(() => {
const role = auth.user?.role;
return role === "ADMIN";
});
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
const documentRowClass = ({ row }: { row: DocumentSummary }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
const filteredItems = computed(() =>
items.value.filter((item) => {
if (filters.scope_type && item?.scope_type !== filters.scope_type) return false;
if (filters.site_id && item?.site_id !== filters.site_id) return false;
if (filters.doc_type && item?.doc_type !== filters.doc_type) return false;
return true;
})
);
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
);
const createForm = reactive({
@@ -240,12 +242,7 @@ const load = async () => {
if (!trialId.value) return;
loading.value = true;
try {
const { data } = await fetchDocuments({
trial_id: trialId.value,
scope_type: filters.scope_type || undefined,
site_id: filters.site_id || undefined,
doc_type: filters.doc_type || undefined,
});
const { data } = await fetchDocuments({ trial_id: trialId.value });
items.value = data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -258,7 +255,6 @@ const resetFilters = () => {
filters.scope_type = "";
filters.site_id = "";
filters.doc_type = "";
load();
};
const openCreate = () => {
@@ -343,24 +339,43 @@ watch(
</script>
<style scoped>
.text-secondary {
color: var(--ctms-text-secondary);
.ctms-page {
display: flex;
flex-direction: column;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.font-medium {
font-weight: 500;
.filter-container {
margin-bottom: 20px;
}
.text-primary {
color: var(--ctms-primary);
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
}
.font-semibold {
font-weight: 600;
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select-comp {
width: 140px;
}
.filter-spacer {
flex: 1;
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
</style>
@@ -89,6 +89,11 @@ const load = async () => {
try {
const { data } = await getDrugShipment(studyId, shipmentId);
Object.assign(detail, data);
// 同步中心名称到面包屑
if (detail.site_name) {
study.setViewContext({ siteName: detail.site_name });
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -1,10 +1,5 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeContracts.detailTitle }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeContracts.detailSubtitle }}</p>
</div>
<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>
@@ -14,7 +9,6 @@
{{ TEXT.common.actions.back }}
</el-button>
</div>
</div>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
@@ -201,6 +195,11 @@ const load = async () => {
const { data } = await getContractFee(contractId);
Object.assign(detail, data?.data || {});
if (!Array.isArray(detail.payments)) detail.payments = [];
// 同步中心名称到面包屑
if (centerName.value) {
study.setViewContext({ siteName: centerName.value });
}
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
@@ -233,7 +232,7 @@ onMounted(async () => {
.page {
display: flex;
flex-direction: column;
gap: 20px;
gap: 12px;
}
.page-header {
+95 -107
View File
@@ -1,14 +1,5 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeContracts.title }}</h1>
</div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeContracts.newTitle }}
</el-button>
</div>
<div class="overview">
<el-row :gutter="20">
@@ -55,34 +46,39 @@
</el-row>
</div>
<el-card class="filter-card" shadow="never">
<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">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-input v-model="filters.q" :placeholder="TEXT.common.placeholders.keyword" clearable class="filter-input">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="main-content-card" v-if="!errorMessage && !loading">
<div class="filter-container">
<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">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-input v-model="filters.q" :placeholder="TEXT.common.placeholders.keyword" clearable class="filter-input">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item class="filter-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
<div class="filter-spacer"></div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeContracts.newTitle }}
</el-button>
</el-form>
</div>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
@@ -92,7 +88,6 @@
<StateLoading v-else-if="loading" :rows="6" />
<el-card v-else class="table-card" shadow="never">
<el-table
:data="sortedContracts"
style="width: 100%"
@@ -100,26 +95,26 @@
:row-class-name="contractRowClass"
:header-cell-style="{ background: '#f8f9fb' }"
>
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="180">
<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="160" align="right">
<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="150" align="center">
<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="180">
<el-table-column :label="TEXT.modules.feeContracts.paidSummary" min-width="200">
<template #default="scope">
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.paidTotal }}</span>
@@ -132,7 +127,7 @@
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" min-width="180">
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" min-width="200">
<template #default="scope">
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.verifiedTotal }}</span>
@@ -145,7 +140,7 @@
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.recentDates" min-width="200">
<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>
@@ -157,12 +152,7 @@
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.remark" min-width="180" 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">
<el-table-column :label="TEXT.common.labels.actions" width="100">
<template #default="scope">
<el-button
link
@@ -182,7 +172,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Coin, Wallet, CircleCheck, CircleClose, Plus, Location, Search } from "@element-plus/icons-vue";
@@ -214,14 +204,26 @@ const siteActiveMap = computed(() => {
return map;
});
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const filteredContracts = computed(() => {
const keyword = filters.q.trim().toLowerCase();
return contracts.value.filter((item) => {
if (filters.centerId && item?.center_id !== filters.centerId) return false;
if (keyword) {
const fields = [item?.center_name, item?.contract_no, item?.contract_name];
const matched = fields.some((field) => String(field || "").toLowerCase().includes(keyword));
if (!matched) return false;
}
return true;
});
});
const sortedContracts = computed(() =>
[...contracts.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
[...filteredContracts.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const contractRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const filters = reactive({
centerId: "",
centerId: study.currentSite?.id || "",
q: "",
});
@@ -231,7 +233,7 @@ const toNumber = (value: any) => {
};
const overview = computed(() => {
const totals = contracts.value.reduce(
const totals = filteredContracts.value.reduce(
(acc, item) => {
acc.totalContractAmount += toNumber(item.contract_amount);
acc.totalPaidAmount += toNumber(item.paid_total);
@@ -248,7 +250,7 @@ const overview = computed(() => {
);
return {
...totals,
centerCount: contracts.value.length,
centerCount: filteredContracts.value.length,
};
});
@@ -269,11 +271,7 @@ const load = async () => {
loading.value = true;
errorMessage.value = "";
try {
const { data } = await listContractFees({
projectId,
centerId: filters.centerId || undefined,
q: filters.q || undefined,
});
const { data } = await listContractFees({ projectId });
contracts.value = data?.data || [];
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
@@ -285,7 +283,6 @@ const load = async () => {
const resetFilters = () => {
filters.centerId = "";
filters.q = "";
load();
};
const goNew = () => router.push("/fees/contracts/new");
@@ -327,6 +324,19 @@ const formatAmountWan = (value: any) => {
return (numberValue / 10000).toFixed(2);
};
watch(() => filters.centerId, (val: string) => {
if (val) {
const matched = sites.value.find(s => s.id === val);
if (matched) study.setCurrentSite(matched);
} else {
study.setCurrentSite(null);
}
});
watch(() => study.currentSite, (newSite: any) => {
filters.centerId = newSite?.id || "";
});
onMounted(async () => {
await loadSites();
load();
@@ -337,58 +347,21 @@ onMounted(async () => {
.page {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.5px;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--ctms-text-secondary);
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
.overview {
margin-bottom: 4px;
}
.filter-card {
border: none;
background-color: transparent;
padding: 0;
}
.filter-card :deep(.el-card__body) {
padding: 0;
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
background: white;
padding: 16px;
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
}
.filter-item {
@@ -398,14 +371,17 @@ onMounted(async () => {
.filter-select,
.filter-input {
width: 240px;
width: 140px;
}
.filter-actions {
margin-left: auto !important;
margin-bottom: 0 !important;
}
.filter-spacer {
flex: 1;
}
.table-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
@@ -424,22 +400,32 @@ onMounted(async () => {
.summary-line {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
font-size: 13px;
margin-bottom: 4px;
color: var(--ctms-text-regular);
gap: 8px;
}
.summary-line:last-child {
margin-bottom: 0;
}
.summary-line span:first-child {
color: var(--ctms-text-secondary);
}
.amount-highlight {
font-weight: 600;
font-family: var(--el-font-family);
}
.summary-line .amount-highlight,
.summary-line .amount-muted {
text-align: left;
}
.amount-highlight.success { color: var(--el-color-success); }
.amount-highlight.warning { color: var(--el-color-warning); }
.amount-highlight.info { color: var(--el-color-info); }
@@ -447,9 +433,11 @@ onMounted(async () => {
.date-line {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
font-size: 12px;
margin-bottom: 2px;
gap: 8px;
}
.date-label {
@@ -1,19 +1,13 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeSpecials.detailTitle }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.detailSubtitle }}</p>
</div>
<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 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">
@@ -178,6 +172,11 @@ const load = async () => {
try {
const { data } = await getSpecialExpense(expenseId);
Object.assign(detail, data?.data || {});
// 同步中心名称到面包屑
if (centerName.value) {
study.setViewContext({ siteName: centerName.value });
}
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
+90 -137
View File
@@ -1,20 +1,7 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeSpecials.title }}</h1>
</div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeSpecials.newTitle }}
</el-button>
</div>
<div class="overview">
<div class="overview-header">
<span class="overview-title">{{ TEXT.modules.feeSpecials.overviewTitle }}</span>
<el-tag effect="plain" type="info" size="small">{{ TEXT.modules.feeSpecials.recordCount }} {{ overview.recordCount }}</el-tag>
</div>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
@@ -58,56 +45,51 @@
</el-row>
</div>
<el-card class="filter-card" shadow="never">
<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">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
<el-card shadow="never" class="main-content-card" v-if="!errorMessage && !loading">
<div class="filter-container">
<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">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-select v-model="filters.category" clearable :placeholder="TEXT.common.fields.category" class="filter-select">
<template #prefix>
<el-icon><Menu /></el-icon>
</template>
<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-date-picker
v-model="filters.dateRange"
type="daterange"
unlink-panels
value-format="YYYY-MM-DD"
:start-placeholder="TEXT.common.placeholders.startDate"
:end-placeholder="TEXT.common.placeholders.endDate"
class="filter-date"
/>
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-select v-model="filters.category" clearable :placeholder="TEXT.common.fields.category" class="filter-select">
<template #prefix>
<el-icon><Menu /></el-icon>
</template>
<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-date-picker
v-model="filters.dateRange"
type="daterange"
unlink-panels
value-format="YYYY-MM-DD"
:start-placeholder="TEXT.common.placeholders.startDate"
:end-placeholder="TEXT.common.placeholders.endDate"
class="filter-date"
/>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
</el-card>
<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 v-else class="table-card" shadow="never">
</el-form-item>
<el-form-item class="filter-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
<div class="filter-spacer"></div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeSpecials.newTitle }}
</el-button>
</el-form>
</div>
<el-table
:data="sortedExpenses"
style="width: 100%"
@@ -132,7 +114,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" width="140" align="right">
<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>
@@ -142,7 +124,7 @@
{{ scope.row.description || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column width="140" align="center" :label="TEXT.common.labels.status">
<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>
@@ -180,7 +162,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Coin, Wallet, CircleCheck, Document, Plus, Location, Menu } from "@element-plus/icons-vue";
@@ -214,19 +196,33 @@ const siteActiveMap = computed(() => {
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const expenseRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const filteredExpenses = computed(() => {
const [dateFrom, dateTo] = filters.dateRange || [];
return expenses.value.filter((item) => {
if (filters.centerId && item?.center_id !== filters.centerId) return false;
if (filters.category && item?.category !== filters.category) return false;
if (dateFrom || dateTo) {
const value = item?.happen_date;
if (!value) return false;
if (dateFrom && value < dateFrom) return false;
if (dateTo && value > dateTo) return false;
}
return true;
});
});
const sortedExpenses = computed(() =>
[...expenses.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
[...filteredExpenses.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const filters = reactive({
centerId: "",
centerId: study.currentSite?.id || "",
category: "",
dateRange: [] as string[],
});
const categoryOptions = Object.keys(TEXT.enums.feeSpecialCategory).map((value) => ({
value,
label: TEXT.enums.feeSpecialCategory[value],
label: (TEXT.enums.feeSpecialCategory as any)[value],
}));
const toNumber = (value: any) => {
@@ -235,7 +231,7 @@ const toNumber = (value: any) => {
};
const overview = computed(() => {
const totals = expenses.value.reduce(
const totals = filteredExpenses.value.reduce(
(acc, item) => {
acc.totalAmount += toNumber(item.amount);
if (item.is_paid) acc.paidAmount += toNumber(item.amount);
@@ -252,7 +248,6 @@ const overview = computed(() => {
);
return {
...totals,
recordCount: expenses.value.length,
};
});
@@ -273,14 +268,7 @@ const load = async () => {
loading.value = true;
errorMessage.value = "";
try {
const [dateFrom, dateTo] = filters.dateRange || [];
const { data } = await listSpecialExpenses({
projectId,
centerId: filters.centerId || undefined,
category: filters.category || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
});
const { data } = await listSpecialExpenses({ projectId });
expenses.value = data?.data || [];
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
@@ -293,7 +281,6 @@ const resetFilters = () => {
filters.centerId = "";
filters.category = "";
filters.dateRange = [];
load();
};
const goNew = () => router.push("/fees/special/new");
@@ -333,6 +320,19 @@ const getCategoryType = (category: string) => {
return 'info';
};
watch(() => filters.centerId, (val: string) => {
if (val) {
const matched = sites.value.find(s => s.id === val);
if (matched) study.setCurrentSite(matched);
} else {
study.setCurrentSite(null);
}
});
watch(() => study.currentSite, (newSite: any) => {
filters.centerId = newSite?.id || "";
});
onMounted(async () => {
await loadSites();
load();
@@ -343,71 +343,21 @@ onMounted(async () => {
.page {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.5px;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--ctms-text-secondary);
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
.overview {
margin-bottom: 4px;
}
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px 8px;
}
.overview-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.filter-card {
border: none;
background-color: transparent;
padding: 0;
}
.filter-card :deep(.el-card__body) {
padding: 0;
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
background: white;
padding: 16px;
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
}
.filter-item {
@@ -416,18 +366,21 @@ onMounted(async () => {
}
.filter-select {
width: 200px;
width: 140px;
}
.filter-date {
width: 280px;
width: 220px;
}
.filter-actions {
margin-left: auto !important;
margin-bottom: 0 !important;
}
.filter-spacer {
flex: 1;
}
.table-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
+140 -68
View File
@@ -1,47 +1,53 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.drugShipments.title }}</h1>
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<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">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-select v-model="filters.direction" clearable :placeholder="TEXT.common.fields.direction" class="filter-select">
<template #prefix>
<el-icon><Menu /></el-icon>
</template>
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-select v-model="filters.status" clearable :placeholder="TEXT.common.fields.status" class="filter-select">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<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-form-item class="filter-actions">
<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-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.drugShipments.newTitle }}
</el-button>
</el-form>
</div>
<el-button type="primary" @click="goNew">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item :label="TEXT.common.fields.site">
<el-select v-model="filters.center_id" :placeholder="TEXT.common.placeholders.select" clearable style="width: 200px">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.direction">
<el-select v-model="filters.direction" :placeholder="TEXT.common.placeholders.select" clearable style="width: 160px">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.status">
<el-select v-model="filters.status" :placeholder="TEXT.common.placeholders.select" clearable style="width: 160px">
<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-form-item>
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table
:data="sortedItems"
v-loading="loading"
@@ -52,9 +58,9 @@
<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="100">
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120">
<template #default="scope">
<el-tag :type="scope.row.direction === 'SEND' ? 'primary' : 'warning'" disable-transitions>
<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>
@@ -71,9 +77,9 @@
<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="120">
<el-table-column prop="status" :label="TEXT.common.fields.status" width="140">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)" disable-transitions>
<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>
@@ -101,9 +107,10 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Location, Menu, CircleCheck, Plus } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
@@ -118,7 +125,7 @@ const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const siteActiveMap = ref<Record<string, boolean>>({});
const filters = reactive({
center_id: "",
center_id: study.currentSite?.id || "",
direction: "",
status: "",
});
@@ -144,12 +151,8 @@ const load = async () => {
if (!studyId) return;
loading.value = true;
try {
const { data } = await listDrugShipments(studyId, {
center_id: filters.center_id || undefined,
direction: filters.direction || undefined,
status: filters.status || undefined,
});
items.value = Array.isArray(data) ? data : data.items || [];
const { data } = await listDrugShipments(studyId, {});
items.value = Array.isArray(data) ? data : data?.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -161,7 +164,6 @@ const resetFilters = () => {
filters.center_id = "";
filters.direction = "";
filters.status = "";
load();
};
const goNew = () => router.push("/drug/shipments/new");
@@ -173,8 +175,16 @@ const onRowClick = (row: any) => {
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const filteredItems = computed(() =>
items.value.filter((item) => {
if (filters.center_id && item?.center_id !== filters.center_id) return false;
if (filters.direction && item?.direction !== filters.direction) return false;
if (filters.status && item?.status !== filters.status) return false;
return true;
})
);
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const statusType = (status: string) => {
@@ -212,6 +222,19 @@ const remove = async (row: any) => {
}
};
watch(() => filters.center_id, (val: string) => {
if (val) {
const matched = sites.value.find(s => s.id === val);
if (matched) study.setCurrentSite(matched);
} else {
study.setCurrentSite(null);
}
});
watch(() => study.currentSite, (newSite: any) => {
filters.center_id = newSite?.id || "";
});
onMounted(async () => {
await loadSites();
load();
@@ -222,30 +245,79 @@ onMounted(async () => {
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.page-header {
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
justify-content: space-between;
align-items: flex-end;
width: 100%;
gap: 12px;
align-items: center;
}
.page-title {
margin: 0;
font-size: 22px;
.filter-item {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-actions {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-actions :deep(.el-form-item__content) {
align-items: center;
}
.filter-actions :deep(.el-button) {
height: 36px;
line-height: 36px;
padding: 0 18px;
border-radius: 8px;
}
.filter-select {
width: 140px;
}
.filter-spacer {
flex: 1;
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
.type-tag {
font-weight: 600;
border: none;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
.type-send {
background-color: #fff7ed !important;
color: #ea580c !important;
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
.type-return {
background-color: #fefce8 !important;
color: #ca8a04 !important;
}
.status-tag {
font-weight: 500;
}
.text-muted {
color: var(--ctms-text-placeholder);
}
</style>
+79 -40
View File
@@ -1,26 +1,34 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.financeContracts.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.financeContracts.subtitle }}</p>
</div>
<el-button type="primary" @click="goNew">{{ TEXT.modules.financeContracts.newTitle }}</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item :label="TEXT.common.fields.site">
<el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
</el-form-item>
<el-form-item :label="TEXT.common.fields.contractNo">
<el-input v-model="filters.contract_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
<el-card class="filter-card" shadow="never">
<div class="filter-row">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item-form">
<el-input
v-model="filters.site_name"
:placeholder="TEXT.common.fields.site"
clearable
class="filter-input-comp"
/>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-input
v-model="filters.contract_no"
:placeholder="TEXT.common.fields.contractNo"
clearable
class="filter-input-comp"
/>
</el-form-item>
<el-form-item class="filter-item-form">
<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">
{{ TEXT.modules.financeContracts.newTitle }}
</el-button>
</el-form>
</div>
</el-card>
<el-card>
@@ -99,8 +107,17 @@ const siteActiveMap = computed(() => {
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
const contractRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
const filteredItems = computed(() => {
const siteKeyword = filters.site_name.trim().toLowerCase();
const contractKeyword = filters.contract_no.trim().toLowerCase();
return items.value.filter((item) => {
if (siteKeyword && !String(item?.site_name || "").toLowerCase().includes(siteKeyword)) return false;
if (contractKeyword && !String(item?.contract_no || "").toLowerCase().includes(contractKeyword)) return false;
return true;
});
});
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
);
const loadSites = async () => {
@@ -119,10 +136,7 @@ const load = async () => {
if (!studyId) return;
loading.value = true;
try {
const { data } = await listFinanceContracts(studyId, {
site_name: filters.site_name || undefined,
contract_no: filters.contract_no || undefined,
});
const { data } = await listFinanceContracts(studyId, {}) as any;
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -134,7 +148,6 @@ const load = async () => {
const resetFilters = () => {
filters.site_name = "";
filters.contract_no = "";
load();
};
const goNew = () => router.push("/finance/contracts/new");
@@ -172,29 +185,55 @@ onMounted(async () => {
.page {
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.page-header {
.filter-card {
border: none;
background-color: transparent;
padding: 0;
}
.filter-card :deep(.el-card__body) {
padding: 0;
}
.filter-form {
display: flex;
justify-content: space-between;
align-items: flex-end;
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;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
.filter-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
.filter-input-comp {
width: 160px;
}
.filter-spacer {
flex: 1;
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
+87 -45
View File
@@ -1,31 +1,39 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.financeSpecials.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.subtitle }}</p>
</div>
<el-button type="primary" @click="goNew">{{ TEXT.modules.financeSpecials.newTitle }}</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item :label="TEXT.common.fields.site">
<el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
</el-form-item>
<el-form-item :label="TEXT.common.fields.type">
<el-select v-model="filters.fee_type" :placeholder="TEXT.common.placeholders.select" clearable>
<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>
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
<el-card class="filter-card" shadow="never">
<div class="filter-row">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item-form">
<el-input
v-model="filters.site_name"
:placeholder="TEXT.common.fields.site"
clearable
class="filter-input-comp"
/>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select
v-model="filters.fee_type"
:placeholder="TEXT.common.fields.type"
clearable
class="filter-select-comp"
>
<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 class="filter-item-form">
<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">
{{ TEXT.modules.financeSpecials.newTitle }}
</el-button>
</el-form>
</div>
</el-card>
<el-card>
@@ -109,8 +117,16 @@ const siteActiveMap = computed(() => {
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
const specialRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
const filteredItems = computed(() => {
const keyword = filters.site_name.trim().toLowerCase();
return items.value.filter((item) => {
if (keyword && !String(item?.site_name || "").toLowerCase().includes(keyword)) return false;
if (filters.fee_type && item?.fee_type !== filters.fee_type) return false;
return true;
});
});
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
);
const loadSites = async () => {
@@ -129,10 +145,7 @@ const load = async () => {
if (!studyId) return;
loading.value = true;
try {
const { data } = await listFinanceSpecials(studyId, {
site_name: filters.site_name || undefined,
fee_type: filters.fee_type || undefined,
});
const { data } = await listFinanceSpecials(studyId, {}) as any;
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -144,7 +157,6 @@ const load = async () => {
const resetFilters = () => {
filters.site_name = "";
filters.fee_type = "";
load();
};
const goNew = () => router.push("/finance/special/new");
@@ -182,29 +194,59 @@ onMounted(async () => {
.page {
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.page-header {
.filter-card {
border: none;
background-color: transparent;
padding: 0;
}
.filter-card :deep(.el-card__body) {
padding: 0;
}
.filter-form {
display: flex;
justify-content: space-between;
align-items: flex-end;
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;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
.filter-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
.filter-input-comp {
width: 160px;
}
.filter-select-comp {
width: 140px;
}
.filter-spacer {
flex: 1;
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
</style>
+64 -42
View File
@@ -1,28 +1,34 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.knowledgeNotes.title }}</h1>
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item-form">
<el-input
v-model="filters.site_name"
:placeholder="TEXT.common.fields.site"
clearable
class="filter-input-comp"
/>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-input
v-model="filters.keyword"
:placeholder="TEXT.common.fields.keyword"
clearable
class="filter-input-comp"
/>
</el-form-item>
<el-form-item class="filter-item-form">
<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">
{{ TEXT.modules.knowledgeNotes.newTitle }}
</el-button>
</el-form>
</div>
<el-button type="primary" @click="goNew">{{ TEXT.modules.knowledgeNotes.newTitle }}</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item :label="TEXT.common.fields.site">
<el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
</el-form-item>
<el-form-item :label="TEXT.common.fields.keyword">
<el-input v-model="filters.keyword" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.keyword" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table
:data="sortedItems"
v-loading="loading"
@@ -87,8 +93,17 @@ const siteActiveMap = computed(() => {
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
const noteRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
const filteredItems = computed(() => {
const siteKeyword = filters.site_name.trim().toLowerCase();
const keyword = filters.keyword.trim().toLowerCase();
return items.value.filter((item) => {
if (siteKeyword && !String(item?.site_name || "").toLowerCase().includes(siteKeyword)) return false;
if (keyword && !String(item?.title || "").toLowerCase().includes(keyword)) return false;
return true;
});
});
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
);
const loadSites = async () => {
@@ -107,10 +122,7 @@ const load = async () => {
if (!studyId) return;
loading.value = true;
try {
const { data } = await listKnowledgeNotes(studyId, {
site_name: filters.site_name || undefined,
keyword: filters.keyword || undefined,
});
const { data } = await listKnowledgeNotes(studyId, {}) as any;
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -122,7 +134,6 @@ const load = async () => {
const resetFilters = () => {
filters.site_name = "";
filters.keyword = "";
load();
};
const goNew = () => router.push("/knowledge/notes/new");
@@ -160,29 +171,40 @@ onMounted(async () => {
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
justify-content: space-between;
align-items: flex-end;
width: 100%;
gap: 12px;
align-items: center;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
.filter-input-comp {
width: 160px;
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
.filter-spacer {
flex: 1;
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
</style>
@@ -4,11 +4,6 @@
<div class="page-header">
<div>
<h1 class="page-title">项目概览</h1>
<p class="study-meta">
<span class="study-name">{{ study.currentStudy.name }}</span>
<span class="study-divider">/</span>
<span class="study-code">{{ study.currentStudy.code }}</span>
</p>
</div>
<div class="header-meta">
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
@@ -1,10 +1,5 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.title }}</h1>
</div>
</div>
<el-card class="ctms-table-card">
<el-tabs v-model="activeTab" class="ctms-tabs">
@@ -240,7 +235,7 @@ const loadFeasibility = async () => {
if (!studyId) return;
loadingFeasibility.value = true;
try {
const { data } = await listFeasibility(studyId);
const { data } = await listFeasibility(studyId) as any;
feasibilityItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -254,7 +249,7 @@ const loadEthics = async () => {
if (!studyId) return;
loadingEthics.value = true;
try {
const { data } = await listEthics(studyId);
const { data } = await listEthics(studyId) as any;
ethicsItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -311,6 +306,12 @@ onMounted(() => {
</script>
<style scoped>
.ctms-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.ctms-tab-content {
padding: 16px;
}
+16 -12
View File
@@ -1,10 +1,5 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.title }}</h1>
</div>
</div>
<el-card>
<el-table
@@ -39,6 +34,7 @@
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { createKickoff, listKickoffs } from "../../api/startup";
import { fetchSites } from "../../api/sites";
@@ -49,12 +45,14 @@ import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const kickoffItems = ref<any[]>([]);
const sites = ref<any[]>([]);
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const loading = ref(false);
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
@@ -62,7 +60,10 @@ const memberNameMap = computed(() => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => {
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
const user = m?.user;
if (m?.user_id && !map[m.user_id]) {
map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
}
});
return map;
});
@@ -111,16 +112,19 @@ const loadKickoffs = async () => {
if (!studyId) return;
loading.value = true;
try {
const [sitesResp, kickoffResp, membersResp, usersResp] = await Promise.all([
const requests = [
fetchSites(studyId, { limit: 500 }),
listKickoffs(studyId),
listMembers(studyId, { limit: 500 }),
fetchUsers({ limit: 500 }),
]);
];
if (isAdmin.value) {
requests.push(fetchUsers({ limit: 500 }));
}
const [sitesResp, kickoffResp, membersResp, usersResp] = await Promise.all(requests) as any[];
sites.value = Array.isArray(sitesResp.data) ? sitesResp.data : sitesResp.data.items || [];
kickoffItems.value = Array.isArray(kickoffResp.data) ? kickoffResp.data : kickoffResp.data.items || [];
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
users.value = usersResp.data?.items || [];
users.value = usersResp?.data?.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -141,7 +145,7 @@ const onKickoffRowClick = async (row: any) => {
return;
}
try {
const { data } = await createKickoff(studyId, { site_id: row.site_id });
const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any;
goKickoffDetail(data.id);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
@@ -158,7 +162,7 @@ onMounted(() => {
.page {
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.page-header {
+213 -18
View File
@@ -1,31 +1,91 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.subjectManagement.title }}</h1>
</div>
<el-button type="primary" @click="goNew">{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</el-button>
</div>
<el-card>
<el-card shadow="never" class="main-content-card">
<div class="filter-container">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" 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 label="" 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 label="" 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>
<div class="filter-spacer"></div>
<el-button type="primary" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
</el-button>
</el-form>
</div>
<el-table
:data="sortedItems"
: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.common.fields.subjectNo" min-width="140" />
<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
@@ -40,15 +100,25 @@
</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" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Search, Location, CircleCheck, Plus } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { deleteSubject, fetchSubjects } from "../../api/subjects";
import { fetchSites } from "../../api/sites";
@@ -60,14 +130,31 @@ 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 siteActiveMap = ref<Record<string, boolean>>({});
const filters = ref({
keyword: "",
siteId: study.currentSite?.id || "",
status: "",
});
const pagination = ref({
currentPage: 1,
pageSize: 20,
});
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 () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
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;
@@ -121,12 +208,47 @@ const remove = async (row: any) => {
};
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const filteredItems = computed(() => {
const keyword = filters.value.keyword.trim().toLowerCase();
return items.value.filter((item) => {
if (keyword && !String(item?.subject_no || "").toLowerCase().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 sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
);
const pagedItems = computed(() => {
const start = (pagination.value.currentPage - 1) * pagination.value.pageSize;
const end = start + pagination.value.pageSize;
return sortedItems.value.slice(start, end);
});
const subjectRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
watch(
() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize],
() => {
pagination.value.currentPage = 1;
}
);
watch(
() => filteredItems.value.length,
(total) => {
const maxPage = Math.max(1, Math.ceil(total / pagination.value.pageSize));
if (pagination.value.currentPage > maxPage) {
pagination.value.currentPage = maxPage;
}
}
);
watch(() => study.currentSite, (newSite) => {
filters.value.siteId = newSite?.id || "";
});
onMounted(async () => {
await loadSites();
load();
@@ -137,19 +259,92 @@ onMounted(async () => {
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
.main-content-card :deep(.el-card__body) {
padding: 20px;
}
.filter-container {
margin-bottom: 20px;
}
.filter-form {
display: flex;
justify-content: space-between;
align-items: flex-end;
width: 100%;
gap: 12px;
align-items: center;
}
.page-title {
margin: 0;
font-size: 22px;
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select-comp,
.filter-input-comp {
width: 140px;
}
.filter-spacer {
flex: 1;
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
.subject-info-cell {
display: flex;
align-items: center;
gap: 8px;
}
.subject-no {
font-weight: 600;
color: var(--ctms-text-main);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.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);
padding: 2px 6px;
height: 18px;
line-height: normal;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
box-shadow: 0 1px 2px rgba(225, 29, 72, 0.05);
}
.ae-badge::before {
content: "";
display: inline-block;
width: 5px;
height: 5px;
background-color: #e11d48;
border-radius: 50%;
margin-right: 4px;
}
.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 {
@@ -38,7 +38,7 @@
:x2="axisRight"
:y2="axisBottom"
/>
<g v-for="tick in yTicks" :key="tick.value" class="axis-tick">
<g v-for="tick in yTicks" :key="`${tick.value}-${tick.minor ? 'm' : 'M'}`" class="axis-tick">
<text :x="axisLeft - 12" :y="tickY(tick.value)" class="tick-label">
{{ formatNumber(tick.value) }}
</text>
@@ -142,17 +142,13 @@ const maxValue = computed(() => {
const yTicks = computed(() => {
const max = maxValue.value;
const majorStep = Math.max(1, Math.ceil(max / 4));
const top = majorStep * 4;
const step = max <= 12 ? 1 : Math.max(1, Math.ceil(max / 6));
const top = Math.max(1, Math.ceil(max / step) * step);
const ticks: Array<{ value: number; minor: boolean }> = [];
for (let i = 0; i <= 4; i += 1) {
const value = top - i * majorStep;
for (let value = top; value >= 0; value -= step) {
ticks.push({ value, minor: false });
if (i < 4) {
ticks.push({ value: value - Math.round(majorStep / 2), minor: true });
}
}
return ticks.filter((tick) => tick.value >= 0).sort((a, b) => b.value - a.value);
return ticks;
});
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
+12 -5
View File
@@ -146,6 +146,7 @@
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import {
getKickoff,
@@ -165,6 +166,7 @@ import { TEXT, requiredMessage } from "../../locales";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const loading = ref(false);
const loadingTraining = ref(false);
@@ -175,6 +177,7 @@ const studyId = study.currentStudy?.id || "";
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
@@ -221,7 +224,10 @@ const memberNameMap = computed(() => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => {
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
const user = m?.user;
if (m?.user_id && !map[m.user_id]) {
map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
}
});
return map;
});
@@ -420,10 +426,11 @@ const goBack = () => router.push("/startup/meeting-auth");
onMounted(async () => {
if (studyId) {
const [membersResp, usersResp] = await Promise.all([
listMembers(studyId, { limit: 500 }),
fetchUsers({ limit: 500 }),
]).catch(() => [null, null]);
const requests = [listMembers(studyId, { limit: 500 })];
if (isAdmin.value) {
requests.push(fetchUsers({ limit: 500 }));
}
const [membersResp, usersResp] = await Promise.all(requests).catch(() => [null, null]);
if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
}
+12 -5
View File
@@ -56,6 +56,7 @@
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { createKickoff, getKickoff, updateKickoff } from "../../api/startup";
import { fetchSites } from "../../api/sites";
@@ -67,6 +68,7 @@ import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const saving = ref(false);
@@ -77,6 +79,7 @@ const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: b
const siteId = ref("");
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
@@ -84,7 +87,10 @@ const memberNameMap = computed(() => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => {
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
const user = m?.user;
if (m?.user_id && !map[m.user_id]) {
map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
}
});
return map;
});
@@ -174,10 +180,11 @@ const goBack = () => router.push("/startup/meeting-auth");
onMounted(async () => {
if (studyId.value) {
const [membersResp, usersResp] = await Promise.all([
listMembers(studyId.value, { limit: 500 }),
fetchUsers({ limit: 500 }),
]).catch(() => [null, null]);
const requests = [listMembers(studyId.value, { limit: 500 })];
if (isAdmin.value) {
requests.push(fetchUsers({ limit: 500 }));
}
const [membersResp, usersResp] = await Promise.all(requests).catch(() => [null, null]);
if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
}
+121 -46
View File
@@ -1,27 +1,9 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.subjectDetail.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.subjectDetail.subtitle }}</p>
</div>
<div class="actions">
<template v-if="subjectEditing">
<el-button type="primary" :loading="subjectSaving" @click="saveSubjectEdit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
</template>
<template v-else>
<el-button type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</template>
</div>
</div>
<el-card v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.subjectNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.subjectManagement.screeningNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
<el-select v-if="subjectEditing" v-model="subjectForm.status" size="small">
@@ -202,10 +184,23 @@
</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">{{ displayEnum(TEXT.enums.aeSeriousness, scope.row.seriousness) }}</template>
<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>
</template>
</el-table-column>
<el-table-column prop="causality" :label="TEXT.common.fields.causality" width="120">
<template #default="scope">{{ displayText(scope.row.causality, TEXT.enums.aeCausality) }}</template>
</el-table-column>
<el-table-column prop="outcome" :label="TEXT.common.fields.outcome" width="120">
<template #default="scope">{{ displayText(scope.row.outcome, TEXT.enums.aeOutcome) }}</template>
</el-table-column>
<el-table-column prop="causality" :label="TEXT.common.fields.causality" width="120" />
<el-table-column prop="outcome" :label="TEXT.common.fields.outcome" width="120" />
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" />
<el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope">
@@ -254,9 +249,9 @@
<el-form :model="visitForm" label-width="100px">
<template v-if="!visitEditingId">
<el-form-item :label="TEXT.common.fields.visitCode" required>
<el-input v-model="visitForm.visit_code" />
<el-input v-model="visitForm.visit_code" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.plannedDate">
<el-form-item :label="TEXT.common.fields.plannedDate" :required="isFirstVisit">
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.windowStart">
@@ -291,22 +286,18 @@
</el-form-item>
<el-form-item :label="TEXT.common.fields.seriousness" required>
<el-select v-model="aeForm.seriousness">
<el-option :label="TEXT.enums.aeSeriousness.SERIOUS" value="SERIOUS" />
<el-option :label="TEXT.enums.aeSeriousness.NON_SERIOUS" value="NON_SERIOUS" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.severity" required>
<el-select v-model="aeForm.severity">
<el-option :label="TEXT.enums.aeSeverity.MILD" value="MILD" />
<el-option :label="TEXT.enums.aeSeverity.MODERATE" value="MODERATE" />
<el-option :label="TEXT.enums.aeSeverity.SEVERE" value="SEVERE" />
<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.causality">
<el-input v-model="aeForm.causality" />
<el-select v-model="aeForm.causality" clearable>
<el-option v-for="option in aeCausalityOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.outcome">
<el-input v-model="aeForm.outcome" />
<el-select v-model="aeForm.outcome" clearable>
<el-option v-for="option in aeOutcomeOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="aeForm.description" type="textarea" :rows="3" />
@@ -330,7 +321,7 @@ import { fetchSites } from "../../api/sites";
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
import { displayDate, displayEnum } from "../../utils/display";
import { displayDate, displayEnum, displayText } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -393,20 +384,62 @@ const visitForm = reactive<any>({
actual_date: "",
notes: "",
});
const isFirstVisit = computed(() => visitForm.visit_code === "V1");
const aeItems = ref<any[]>([]);
const loadingAes = ref(false);
const aeDialogVisible = ref(false);
const aeEditingId = ref<string | null>(null);
const aeCausalityOptions = [
{ value: "肯定有关", label: TEXT.enums.aeCausality["肯定有关"] },
{ value: "很可能有关", label: TEXT.enums.aeCausality["很可能有关"] },
{ value: "可能有关", label: TEXT.enums.aeCausality["可能有关"] },
{ value: "可能无关", label: TEXT.enums.aeCausality["可能无关"] },
{ value: "无关", label: TEXT.enums.aeCausality["无关"] },
{ value: "待评估", label: TEXT.enums.aeCausality["待评估"] },
];
const aeOutcomeOptions = [
{ value: "痊愈", label: TEXT.enums.aeOutcome["痊愈"] },
{ value: "好转", label: TEXT.enums.aeOutcome["好转"] },
{ value: "持续", label: TEXT.enums.aeOutcome["持续"] },
{ value: "恶化", label: TEXT.enums.aeOutcome["恶化"] },
{ value: "死亡", label: TEXT.enums.aeOutcome["死亡"] },
{ value: "失访", label: TEXT.enums.aeOutcome["失访"] },
];
const aeForm = reactive({
term: "",
onset_date: "",
seriousness: "NON_SERIOUS",
severity: "MILD",
seriousness: "I",
causality: "",
outcome: "",
description: "",
});
const aeSeriousnessOptions = [
{ value: "I", label: TEXT.enums.aeSeriousness.I },
{ value: "II", label: TEXT.enums.aeSeriousness.II },
{ value: "III", label: TEXT.enums.aeSeriousness.III },
{ value: "IV", label: TEXT.enums.aeSeriousness.IV },
{ value: "V", label: TEXT.enums.aeSeriousness.V },
];
const normalizeSeriousness = (value?: string | null) => {
if (!value) return "I";
const normalized = String(value).trim().toUpperCase();
const digitMap: Record<string, string> = {
"1": "I",
"2": "II",
"3": "III",
"4": "IV",
"5": "V",
};
const legacyMap: Record<string, string> = {
SERIOUS: "IV",
NON_SERIOUS: "II",
};
if (digitMap[normalized]) return digitMap[normalized];
if (legacyMap[normalized]) return legacyMap[normalized];
if (["I", "II", "III", "IV", "V"].includes(normalized)) return normalized;
return "I";
};
const loadSites = async () => {
if (!studyId) return;
@@ -487,7 +520,7 @@ const loadHistories = async () => {
if (!studyId || !subjectId) return;
loadingHistory.value = true;
try {
const { data } = await listSubjectHistories(studyId, subjectId);
const { data } = (await listSubjectHistories(studyId, subjectId)) as any;
historyItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -551,8 +584,17 @@ const loadVisits = async () => {
if (!studyId || !subjectId) return;
loadingVisits.value = true;
try {
const { data } = await fetchVisits(studyId, subjectId);
visitItems.value = Array.isArray(data) ? data : data.items || [];
const { data } = (await fetchVisits(studyId, subjectId)) as any;
const items = Array.isArray(data) ? data : data.items || [];
visitItems.value = items.sort((a: any, b: any) => {
const getNum = (code: any) => {
const text = String(code || "");
if (!text.startsWith("V")) return Number.POSITIVE_INFINITY;
const num = Number(text.slice(1));
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
};
return getNum(a?.visit_code) - getNum(b?.visit_code);
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -560,6 +602,21 @@ const loadVisits = async () => {
}
};
const getNextVisitCode = () => {
const used = new Set<number>();
visitItems.value.forEach((item) => {
const code = String(item?.visit_code || "");
if (!code.startsWith("V")) return;
const num = Number(code.slice(1));
if (Number.isFinite(num) && num > 0) {
used.add(num);
}
});
let next = 1;
while (used.has(next)) next += 1;
return `V${next}`;
};
const openVisitDialog = (row?: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
@@ -570,7 +627,7 @@ const openVisitDialog = (row?: any) => {
visitForm.actual_date = row.actual_date || "";
visitForm.notes = row.notes || "";
} else {
visitForm.visit_code = "";
visitForm.visit_code = getNextVisitCode();
visitForm.planned_date = "";
visitForm.window_start = "";
visitForm.window_end = "";
@@ -657,6 +714,22 @@ 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 saveVisit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
@@ -671,6 +744,10 @@ const saveVisit = async () => {
};
await updateVisit(studyId, subjectId, visitEditingId.value, payload);
} else {
if (visitForm.visit_code === "V1" && !visitForm.planned_date) {
ElMessage.error("首次新增访视必须填写计划访视日期");
return;
}
const payload = {
subject_id: subjectId,
visit_code: visitForm.visit_code,
@@ -707,7 +784,7 @@ const loadAes = async () => {
if (!studyId || !subjectId) return;
loadingAes.value = true;
try {
const { data } = await fetchAes(studyId, { subject_id: subjectId });
const { data } = (await fetchAes(studyId, { subject_id: subjectId })) as any;
aeItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -725,8 +802,7 @@ const openAeDialog = (row?: any) => {
Object.assign(aeForm, {
term: row?.term || "",
onset_date: row?.onset_date || "",
seriousness: row?.seriousness || "NON_SERIOUS",
severity: row?.severity || "MILD",
seriousness: normalizeSeriousness(row?.seriousness),
causality: row?.causality || "",
outcome: row?.outcome || "",
description: row?.description || "",
@@ -747,7 +823,6 @@ const saveAe = async () => {
term: aeForm.term,
onset_date: aeForm.onset_date || null,
seriousness: aeForm.seriousness,
severity: aeForm.severity,
causality: aeForm.causality || null,
outcome: aeForm.outcome || null,
description: aeForm.description || null,
+2 -2
View File
@@ -10,11 +10,11 @@
<el-card>
<el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.common.fields.subjectNo" required>
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required>
<el-input
v-model="form.subject_no"
:disabled="isEdit || isReadOnly"
:placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo"
:placeholder="TEXT.common.placeholders.input + TEXT.modules.subjectManagement.screeningNo"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.site" required>
+461 -103
View File
@@ -4,9 +4,6 @@
<div class="workbench-header">
<div class="header-content">
<h1 class="welcome-title">{{ TEXT.modules.workbench.title }}</h1>
<p class="welcome-subtitle">
{{ TEXT.modules.workbench.subtitle.replace("{role}", roleLabel) }}
</p>
</div>
<div class="header-actions">
<el-tag effect="dark" class="role-badge">{{ roleLabel }}</el-tag>
@@ -24,6 +21,9 @@
/>
<template v-if="currentStudyId">
<!-- 汇总框 -->
<CenterSummary :data="centerSummaryData" :loading="loading" />
<!-- 核心看板层 -->
<div class="stats-row">
<div class="stats-col">
@@ -36,13 +36,41 @@
/>
</div>
<div class="stats-col">
<SectionCard
:title="TEXT.modules.workbench.overdueTitle"
:items="overdueList"
:loading="loading"
:empty-text="TEXT.modules.workbench.overdueEmpty"
@more="goMore(overdueMorePath)"
/>
<div class="notifications-card">
<div class="notifications-header">
<div class="notifications-title-group">
<span class="notifications-title">{{ TEXT.modules.projectOverview.notificationsTitle }}</span>
<span class="notifications-subtitle">{{ TEXT.modules.projectOverview.notificationsSubtitle }}</span>
</div>
<el-button v-if="notifications.length" type="primary" link @click="goMore('/documents')" class="notifications-more">
{{ TEXT.common.actions.more }} <el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
</div>
<el-skeleton v-if="loading" :rows="3" animated />
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
<div v-else class="notification-list">
<div
class="notification-item"
v-for="item in notifications.slice(0, 5)"
:key="item.id"
@click="openDocument(item.document_id)"
>
<div class="notification-main">
<div class="notification-title">
<span class="notification-doc">{{ item.document_title }}</span>
<span class="notification-version">V{{ item.version_no }}</span>
</div>
<div class="notification-meta">
<span class="notification-time">{{ formatDate(item.effective_at || item.created_at) }}</span>
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
</div>
</div>
<div class="notification-update-badge">
<span class="update-text">更新</span>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -50,11 +78,11 @@
<div class="secondary-row">
<div class="action-col">
<SectionCard
:title="TEXT.modules.workbench.actionTitle"
:items="actionList"
:title="TEXT.modules.workbench.overdueTitle"
:items="overdueList"
:loading="loading"
:empty-text="TEXT.modules.workbench.actionEmpty"
@more="goMore(actionMorePath)"
:empty-text="TEXT.modules.workbench.overdueEmpty"
@more="goMore(overdueMorePath)"
/>
</div>
<div class="quick-col">
@@ -83,10 +111,6 @@
</div>
</div>
<!-- 个人备忘层 -->
<div v-if="showPersonalTodo" class="todo-row">
<PersonalTodo :storage-key="todoStorageKey" />
</div>
</template>
</div>
</template>
@@ -98,10 +122,14 @@ import { ElMessage } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { fetchAes } from "../../api/aes";
import { listFinanceContracts } from "../../api/financeContracts";
import { Right, Timer, UserFilled, Warning, Money, Collection } from "@element-plus/icons-vue";
import { fetchLostVisits, fetchCenterSummary } from "../../api/dashboard";
import { listNotifications } from "../../api/notifications";
import { Right, Timer, Warning, Money, Collection, RefreshRight, ArrowRight } from "@element-plus/icons-vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { displayEnum } from "../../utils/display";
import { TEXT } from "../../locales";
import type { NotificationItem } from "../../types/notifications";
import type { VisitLostItem } from "../../types/visits";
interface WorkItem {
title: string;
@@ -112,8 +140,7 @@ interface WorkItem {
}
const SectionCard = defineAsyncComponent(() => import("./components/SectionCard.vue"));
const PersonalTodo = defineAsyncComponent(() => import("./components/PersonalTodo.vue"));
const CenterSummary = defineAsyncComponent(() => import("./components/CenterSummary.vue"));
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
@@ -121,15 +148,14 @@ const router = useRouter();
const loading = ref(false);
const todayList = ref<WorkItem[]>([]);
const overdueList = ref<WorkItem[]>([]);
const actionList = ref<WorkItem[]>([]);
const notifications = ref<NotificationItem[]>([]);
const lostVisits = ref<VisitLostItem[]>([]);
const centerSummaryData = ref<any[]>([]);
const currentStudyId = computed(() => study.currentStudy?.id || "");
const todayStr = computed(() => new Date().toISOString().slice(0, 10));
const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, auth.user?.role));
const emptyText = TEXT.modules.workbench.quickEmpty;
const showPersonalTodo = computed(() => role.value !== "ADMIN");
const todoStorageKey = computed(() => `workbench_todos_${auth.user?.id || "guest"}`);
const role = computed(() => auth.user?.role || "");
const quickActions = computed(() => {
if (!currentStudyId.value) return [];
@@ -150,10 +176,6 @@ const quickActions = computed(() => {
const todayMorePath = computed(() => "/subjects");
const overdueMorePath = computed(() => "/subjects");
const actionMorePath = computed(() => {
if (role.value === "PM" || role.value === "ADMIN") return "/finance/contracts";
return "/subjects";
});
const isOverdue = (d?: string | null) => {
if (!d) return false;
@@ -162,18 +184,18 @@ const isOverdue = (d?: string | null) => {
const toAeItem = (ae: any): WorkItem => ({
title: ae.term || TEXT.modules.workbench.aeTitleFallback,
subtitle: study.currentStudy?.name || "",
subtitle: "",
status: ae.status,
overdue: isOverdue(ae.expected_resolution_date || ae.updated_at),
path: ae.subject_id ? `/subjects/${ae.subject_id}` : "/subjects",
});
const toContractItem = (fi: any): WorkItem => ({
title: fi.contract_no || TEXT.modules.workbench.contractTitleFallback,
subtitle: study.currentStudy?.name || "",
status: fi.site_name,
overdue: false,
path: `/finance/contracts/${fi.id}`,
const toLostVisitItem = (visit: VisitLostItem): WorkItem => ({
title: `${visit.subject_no} ${visit.visit_code}`,
subtitle: "",
status: isNewLostVisit(visit.updated_at) ? TEXT.modules.workbench.newLostVisit : TEXT.enums.visitStatus.LOST,
overdue: true,
path: `/subjects/${visit.subject_id}`,
});
const loadData = async () => {
@@ -182,13 +204,21 @@ const loadData = async () => {
try {
const studyId = currentStudyId.value;
const aesReq = fetchAes(studyId, { status: "NEW", limit: 50 });
const [aesRes, financeRes] = await Promise.allSettled([aesReq, listFinanceContracts(studyId, { limit: 50 })]);
const overdueReq = fetchAes(studyId, { overdue: true, limit: 50 });
const [aesRes, overdueRes, noticeRes, lostRes, centerRes] = await Promise.allSettled([
aesReq,
overdueReq,
listNotifications(studyId, { limit: 5 }),
fetchLostVisits(studyId, { limit: 20 }),
fetchCenterSummary(studyId),
]);
const aes = aesRes.status === "fulfilled" ? (aesRes.value.data.items || aesRes.value.data || []) : [];
const finances =
financeRes.status === "fulfilled" ? (financeRes.value.data.items || financeRes.value.data || []) : [];
const overdueAes = overdueRes.status === "fulfilled" ? (overdueRes.value.data.items || overdueRes.value.data || []) : [];
notifications.value = noticeRes.status === "fulfilled" ? noticeRes.value.data || [] : [];
lostVisits.value = lostRes.status === "fulfilled" ? lostRes.value.data || [] : [];
centerSummaryData.value = centerRes.status === "fulfilled" ? centerRes.value.data || [] : [];
buildSections(aes, finances);
buildSections(aes, overdueAes, lostVisits.value);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.workbench.loadFailed);
} finally {
@@ -196,29 +226,23 @@ const loadData = async () => {
}
};
const buildSections = (aes: any[], finances: any[]) => {
const buildSections = (aes: any[], overdueAes: any[], lost: VisitLostItem[]) => {
todayList.value = [];
overdueList.value = [];
actionList.value = [];
const openAes = aes.filter((a) => a.status !== "CLOSED");
const financePending = finances;
const overdueAes = openAes.filter((a) => isOverdue(a.expected_resolution_date || a.updated_at));
const lostVisitItems = lost.map(toLostVisitItem);
if (role.value === "CRA") {
todayList.value = openAes.slice(0, 5).map(toAeItem);
overdueList.value = overdueAes.slice(0, 5).map(toAeItem);
actionList.value = openAes.slice(0, 5).map(toAeItem);
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
} else if (role.value === "PM" || role.value === "ADMIN") {
todayList.value = openAes.slice(0, 5).map(toAeItem);
overdueList.value = overdueAes.slice(0, 5).map(toAeItem);
const financeTodo = financePending.slice(0, 3).map(toContractItem);
actionList.value = [...financeTodo, ...openAes.slice(0, 2).map(toAeItem)].slice(0, 5);
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
} else if (role.value === "PV") {
const aeOpen = aes.filter((a) => a.status !== "CLOSED");
todayList.value = aeOpen.slice(0, 5).map(toAeItem);
overdueList.value = aeOpen.filter((a) => isOverdue(a.updated_at)).slice(0, 5).map(toAeItem);
actionList.value = aeOpen.slice(0, 5).map(toAeItem);
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
}
};
@@ -227,6 +251,23 @@ const goMore = (path: string) => {
router.push(path);
};
const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
const isNewLostVisit = (value?: string | null) => {
if (!value) return false;
const updatedAt = new Date(value);
if (Number.isNaN(updatedAt.getTime())) return false;
const now = new Date();
return now.getTime() - updatedAt.getTime() <= 24 * 60 * 60 * 1000;
};
const openDocument = (documentId: string) => {
router.push(`/documents/${documentId}`);
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
@@ -239,141 +280,458 @@ onMounted(async () => {
.workbench-container {
display: flex;
flex-direction: column;
gap: 24px;
gap: 28px;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 头部区域 - 添加渐变背景 */
.workbench-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
margin: -8px -8px 0 -8px;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.2);
position: relative;
overflow: hidden;
}
.workbench-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
opacity: 0.4;
}
.header-content {
position: relative;
z-index: 1;
}
.header-actions {
position: relative;
z-index: 1;
}
.welcome-title {
font-size: 28px;
font-weight: 700;
color: var(--ctms-text-main);
font-size: 32px;
font-weight: 800;
color: #ffffff;
margin: 0;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
letter-spacing: -0.5px;
}
.welcome-subtitle {
margin: 8px 0 0;
font-size: 15px;
color: var(--ctms-text-secondary);
margin: 10px 0 0;
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.role-badge {
background-color: var(--ctms-primary);
border: none;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #ffffff;
font-weight: 600;
padding: 0 12px;
height: 28px;
line-height: 28px;
padding: 0 16px;
height: 32px;
line-height: 32px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.stats-row, .secondary-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
gap: 20px;
}
.todo-row {
margin-top: 8px;
}
.study-alert {
border: none;
border-left: 4px solid var(--ctms-warning);
background-color: #fffbe6;
background: linear-gradient(90deg, #fffbe6 0%, #ffffff 100%);
box-shadow: var(--ctms-shadow-sm);
}
/* 快捷入口卡片样式 */
/* 快捷入口卡片样式 - 大幅优化 */
.quick-entry-card {
background-color: #fff;
border: 1px solid var(--ctms-border-color);
border-radius: 8px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%);
border: 1px solid rgba(102, 126, 234, 0.1);
border-radius: 16px;
height: 100%;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
}
.quick-entry-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.entry-header {
padding: 16px 20px;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--ctms-border-color);
background-color: #fafafa;
border-bottom: 1px solid rgba(102, 126, 234, 0.08);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.02) 100%);
}
.entry-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
font-size: 17px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.entry-more {
font-size: 13px;
font-weight: 600;
}
.quick-grid {
padding: 20px;
padding: 24px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 20px;
flex: 1;
}
.quick-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
gap: 12px;
cursor: pointer;
transition: all 0.3s ease;
padding: 12px;
border-radius: 8px;
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
padding: 16px 12px;
border-radius: 12px;
background: #ffffff;
border: 1px solid transparent;
}
.quick-item:hover {
background-color: var(--ctms-primary-light);
transform: translateY(-2px);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 28px rgba(102, 126, 234, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
.quick-icon-box {
width: 40px;
height: 40px;
background-color: #fff;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
width: 56px;
height: 56px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--ctms-primary);
box-shadow: var(--ctms-shadow-sm);
transition: all 0.3s ease;
font-size: 26px;
color: #ffffff;
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
}
.quick-icon-box::before {
content: '';
position: absolute;
inset: 0;
border-radius: 16px;
padding: 2px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.quick-item:hover .quick-icon-box {
border-color: var(--ctms-primary);
background-color: var(--ctms-primary);
color: #fff;
background: #ffffff;
color: #667eea;
transform: scale(1.1) rotate(5deg);
box-shadow: 0 12px 32px rgba(255, 255, 255, 0.4);
}
.quick-label {
font-size: 13px;
font-weight: 500;
font-weight: 600;
color: var(--ctms-text-regular);
transition: all 0.3s ease;
text-align: center;
}
.quick-item:hover .quick-label {
color: #ffffff;
transform: scale(1.05);
}
/* 通知卡片 - 增强样式 */
.notifications-card {
background: linear-gradient(135deg, #ffffff 0%, #fef7ff 100%);
border: 1px solid rgba(118, 75, 162, 0.1);
border-radius: 16px;
padding: 20px 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
}
.notifications-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid rgba(118, 75, 162, 0.08);
}
.notifications-title-group {
display: flex;
align-items: baseline;
gap: 10px;
}
.notifications-more {
font-size: 13px;
font-weight: 600;
transition: all 0.3s ease;
}
.notifications-more:hover {
transform: translateX(4px);
}
.notifications-title {
font-size: 17px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.notifications-subtitle {
font-size: 12px;
color: var(--ctms-text-secondary);
font-weight: 500;
}
.notification-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.notification-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 12px;
background: #ffffff;
border: 1px solid rgba(102, 126, 234, 0.08);
transition: all 0.3s ease;
cursor: pointer;
}
.notification-item:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
border-color: rgba(102, 126, 234, 0.2);
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
.notification-main {
display: flex;
flex-direction: column;
gap: 6px;
}
.notification-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--ctms-text-main);
font-size: 14px;
}
.notification-version {
font-size: 11px;
padding: 3px 8px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
font-weight: 700;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.notification-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.notification-summary {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 锯齿状更新徽章 - 扁平版 */
.notification-update-badge {
position: relative;
width: 56px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s ease;
}
.notification-update-badge::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #ff4757 0%, #ff6b81 100%);
clip-path: polygon(
10% 0%,
20% 8%,
30% 0%,
40% 8%,
50% 0%,
60% 8%,
70% 0%,
80% 8%,
90% 0%,
100% 15%,
95% 50%,
100% 85%,
90% 100%,
80% 92%,
70% 100%,
60% 92%,
50% 100%,
40% 92%,
30% 100%,
20% 92%,
10% 100%,
0% 85%,
5% 50%,
0% 15%
);
transition: all 0.3s ease;
}
/* 透明内层 - 镂空效果 */
.notification-update-badge::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: calc(100% - 4px);
height: calc(100% - 4px);
background: #ffffff;
clip-path: polygon(
10% 0%,
20% 10%,
30% 0%,
40% 10%,
50% 0%,
60% 10%,
70% 0%,
80% 10%,
90% 0%,
100% 18%,
94% 50%,
100% 82%,
90% 100%,
80% 90%,
70% 100%,
60% 90%,
50% 100%,
40% 90%,
30% 100%,
20% 90%,
10% 100%,
0% 82%,
6% 50%,
0% 18%
);
transition: all 0.3s ease;
}
.update-text {
position: relative;
z-index: 2;
font-size: 11px;
font-weight: 800;
color: #1a1a2e;
letter-spacing: 1px;
}
.notification-item:hover .notification-update-badge {
transform: scale(1.1);
}
.notification-item:hover .notification-update-badge::before {
box-shadow: 0 4px 16px rgba(255, 71, 87, 0.4);
}
.empty-quick {
padding: 40px;
padding: 48px 24px;
text-align: center;
color: var(--ctms-text-disabled);
font-size: 14px;
}
@media (max-width: 992px) {
.stats-row, .secondary-row {
grid-template-columns: 1fr;
}
.workbench-header {
padding: 24px;
margin: 0;
}
.welcome-title {
font-size: 26px;
}
}
</style>
@@ -0,0 +1,100 @@
<template>
<div class="center-summary-box">
<div class="box-header">
<span class="box-title">各中心进展汇总</span>
</div>
<div class="summary-table-container">
<el-table
:data="data"
style="width: 100%"
v-loading="loading"
empty-text="暂无数据"
class="custom-table"
>
<el-table-column prop="name" label="管理中心" min-width="180">
<template #default="{ row }">
<span class="center-name">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="stage" label="进展阶段" width="120">
<template #default="{ row }">
<el-tag :type="getStageType(row.stage_status)" effect="light" size="small" round>
{{ row.stage }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="实际入组例数/计划例数" width="200" align="right">
<template #default="{ row }">
<div class="enrollment-ratio">
<span class="actual">{{ row.actual_enrolled }}</span>
<span class="separator">/</span>
<span class="planned">{{ row.planned_enrolled }}</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
data: any[];
loading: boolean;
}>();
const getStageType = (status?: string) => {
if (status === "COMPLETED") return "success";
if (status === "IN_PROGRESS") return "warning";
if (status === "BLOCKED") return "danger";
return "info";
};
</script>
<style scoped>
.center-summary-box {
background: #ffffff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
margin-bottom: 24px;
}
.box-header {
margin-bottom: 16px;
}
.box-title {
font-size: 18px;
font-weight: 700;
color: var(--ctms-text-main, #1a1a2e);
}
.center-name {
font-weight: 600;
color: var(--ctms-text-main, #1a1a2e);
}
.enrollment-ratio {
font-family: 'Roboto Mono', monospace;
font-weight: 600;
}
.actual {
color: var(--ctms-primary, #667eea);
}
.separator {
margin: 0 4px;
color: #ccc;
}
.planned {
color: var(--ctms-text-secondary, #808080);
}
</style>
@@ -2,7 +2,12 @@
<el-card shadow="never" class="section-card">
<template #header>
<div class="section-header">
<span class="section-title">{{ title }}</span>
<div class="section-title-wrapper">
<div class="section-icon">
<el-icon><component :is="getIcon()" /></el-icon>
</div>
<span class="section-title">{{ title }}</span>
</div>
<el-button v-if="!loading" type="primary" link @click="$emit('more')" class="more-btn">
{{ TEXT.common.actions.more }} <el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
@@ -43,12 +48,12 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { ArrowRight } from "@element-plus/icons-vue";
import { ArrowRight, Calendar, Warning, Document } from "@element-plus/icons-vue";
import StateEmpty from "../../../components/StateEmpty.vue";
import StateLoading from "../../../components/StateLoading.vue";
import { TEXT } from "../../../locales";
defineProps<{
const props = defineProps<{
title: string;
items: any[];
loading?: boolean;
@@ -62,32 +67,89 @@ const go = (path: string) => {
if (!path) return;
router.push(path);
};
const getIcon = () => {
// 根据标题返回不同的图标
if (props.title.includes("今日") || props.title.includes("待办")) return Calendar;
if (props.title.includes("逾期")) return Warning;
return Document;
};
</script>
<style scoped>
.section-card {
border: 1px solid var(--ctms-border-color);
border-radius: 8px;
border: 1px solid rgba(102, 126, 234, 0.12);
border-radius: 16px;
height: 100%;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #ffffff 0%, #fafcff 100%);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
border-color: rgba(102, 126, 234, 0.2);
}
:deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid var(--ctms-border-color);
background-color: #fafafa;
padding: 20px 24px;
border-bottom: 1px solid rgba(102, 126, 234, 0.08);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.02) 100%);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-title-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.section-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
font-size: 18px;
color: #ffffff;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25);
transition: all 0.3s ease;
}
.section-card:hover .section-icon {
transform: rotate(-5deg) scale(1.05);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.35);
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
font-size: 17px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.more-btn {
font-size: 13px;
color: var(--ctms-primary);
font-weight: 600;
transition: all 0.3s ease;
}
.more-btn:hover {
transform: translateX(4px);
}
.section-body {
@@ -101,13 +163,25 @@ const go = (path: string) => {
}
.list-item {
padding: 16px 20px;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--ctms-border-color);
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
border-bottom: 1px solid rgba(102, 126, 234, 0.06);
position: relative;
}
.list-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s ease;
}
.list-item:last-child {
@@ -115,23 +189,35 @@ const go = (path: string) => {
}
.list-item:hover {
background-color: var(--ctms-primary-light);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.04) 0%, rgba(118, 75, 162, 0.04) 100%);
transform: translateX(6px);
}
.list-item:hover::before {
width: 4px;
}
.item-main {
flex: 1;
overflow: hidden;
margin-right: 16px;
position: relative;
z-index: 1;
}
.item-title {
font-size: 14px;
font-weight: 500;
font-weight: 600;
color: var(--ctms-text-main);
margin-bottom: 4px;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.3s ease;
}
.list-item:hover .item-title {
color: #667eea;
}
.item-subtitle {
@@ -140,13 +226,41 @@ const go = (path: string) => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.item-meta {
position: relative;
z-index: 1;
}
.status-tag {
border-radius: 4px;
border-radius: 12px;
font-weight: 600;
font-size: 12px;
padding: 4px 10px;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.status-tag.el-tag--danger {
background: linear-gradient(135deg, rgba(194, 75, 75, 0.12) 0%, rgba(194, 75, 75, 0.08) 100%);
color: var(--ctms-danger);
border-color: rgba(194, 75, 75, 0.2);
}
.status-tag.el-tag--info {
background: linear-gradient(135deg, rgba(100, 116, 139, 0.12) 0%, rgba(100, 116, 139, 0.08) 100%);
color: var(--ctms-info);
border-color: rgba(100, 116, 139, 0.2);
}
.list-item:hover .status-tag {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.empty-state {
padding: 8px 0;
padding: 16px 0;
}
</style>