未知(继上次中断)

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
+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>