前端UI界面美化-2

This commit is contained in:
Cheng Zhou
2025-12-22 08:54:34 +08:00
parent 4f510871bf
commit 03fddba406
8 changed files with 873 additions and 257 deletions
+84 -19
View File
@@ -1,9 +1,23 @@
<template> <template>
<el-card class="kpi-card" shadow="hover"> <el-card class="kpi-card" shadow="never">
<div class="title">{{ title }}</div> <div class="kpi-header">
<div class="value" v-if="!loading">{{ value }}</div> <span class="kpi-title">{{ title }}</span>
<div class="value loading" v-else>加载中...</div> <el-icon v-if="icon" class="kpi-icon"><component :is="icon" /></el-icon>
<div class="subtext" v-if="subtext">{{ subtext }}</div> </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> </el-card>
</template> </template>
@@ -13,6 +27,8 @@ interface Props {
value: string | number; value: string | number;
subtext?: string; subtext?: string;
loading?: boolean; loading?: boolean;
icon?: any;
unit?: string;
} }
defineProps<Props>(); defineProps<Props>();
@@ -20,22 +36,71 @@ defineProps<Props>();
<style scoped> <style scoped>
.kpi-card { .kpi-card {
min-height: 120px; border: 1px solid var(--ctms-border-color);
transition: all 0.3s ease;
height: 100%;
} }
.title {
color: #909399; .kpi-card:hover {
border-color: var(--ctms-primary-hover);
box-shadow: var(--ctms-shadow-md) !important;
transform: translateY(-2px);
}
.kpi-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.kpi-title {
color: var(--ctms-text-secondary);
font-size: 14px;
font-weight: 500;
}
.kpi-icon {
font-size: 18px;
color: var(--ctms-primary);
opacity: 0.6;
}
.kpi-content {
display: flex;
flex-direction: column;
}
.kpi-value-container {
display: flex;
align-items: baseline;
gap: 4px;
}
.kpi-value {
font-size: 32px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.2;
}
.kpi-unit {
font-size: 14px;
color: var(--ctms-text-secondary);
font-weight: 500;
}
.kpi-subtext {
color: var(--ctms-text-secondary);
font-size: 13px; font-size: 13px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--ctms-border-color);
} }
.value {
font-size: 28px; .kpi-loading {
font-weight: 600; height: 38px;
margin: 8px 0; display: flex;
} align-items: center;
.subtext {
color: #909399;
font-size: 12px;
}
.loading {
color: #c0c4cc;
} }
</style> </style>
+13 -4
View File
@@ -90,9 +90,7 @@
<el-container class="main-container"> <el-container class="main-container">
<el-header class="layout-header"> <el-header class="layout-header">
<div class="header-left"> <div class="header-left">
<div class="breadcrumb-study" v-if="study.currentStudy"> <!-- 移除冗余的 study-tag StudySelector 统一展示交互 -->
<el-tag effect="plain" class="study-tag">{{ study.currentStudy.name }}</el-tag>
</div>
</div> </div>
<div class="header-right"> <div class="header-right">
<StudySelector /> <StudySelector />
@@ -215,11 +213,22 @@ const onLogout = () => {
:deep(.el-sub-menu__title) { :deep(.el-sub-menu__title) {
color: rgba(255, 255, 255, 0.65) !important; color: rgba(255, 255, 255, 0.65) !important;
margin: 4px 12px; margin: 4px 12px;
border-radius: var(--ctms-radius);
} }
:deep(.el-sub-menu__title:hover) { :deep(.el-sub-menu__title:hover) {
color: #fff !important; color: #fff !important;
background-color: transparent !important; background-color: rgba(255, 255, 255, 0.1) !important;
}
:deep(.el-menu--inline) {
background-color: rgba(0, 0, 0, 0.2) !important;
margin: 0 !important;
}
:deep(.el-menu--inline .el-menu-item) {
padding-left: 48px !important;
margin: 2px 12px;
} }
.layout-header { .layout-header {
+108 -15
View File
@@ -1,35 +1,128 @@
<template> <template>
<el-card> <div class="quick-actions-section">
<div class="actions"> <div class="section-header">
<el-button v-for="item in actions" :key="item.path" type="primary" plain @click="go(item.path)"> <el-icon class="header-icon"><Pointer /></el-icon>
{{ item.label }} <span class="header-title">通往业务模块的快捷入口</span>
</el-button>
</div> </div>
</el-card> <div class="actions-grid">
<div
v-for="item in actions"
:key="item.path"
class="action-item"
@click="go(item.path)"
>
<div class="action-icon-wrapper" :style="{ backgroundColor: item.color + '20', color: item.color }">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<span class="action-label">{{ item.label }}</span>
<el-icon class="action-arrow"><ArrowRight /></el-icon>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import {
Pointer, ArrowRight, Timer, UserFilled, Warning,
QuestionFilled, Management, Money, Collection
} from "@element-plus/icons-vue";
const router = useRouter(); const router = useRouter();
const actions = [ const actions = [
{ label: "伦理与启动", path: "/study/milestones" }, { label: "里程碑管理", path: "/study/milestones", icon: Timer, color: "#2f54eb" },
{ label: "受试者", path: "/study/subjects" }, { label: "受试者", path: "/study/subjects", icon: UserFilled, color: "#722ed1" },
{ label: "AE", path: "/study/aes" }, { label: "不良事件", path: "/study/aes", icon: Warning, color: "#faad14" },
{ label: "数据问题", path: "/study/data-queries" }, { label: "数据问题", path: "/study/data-queries", icon: QuestionFilled, color: "#1890ff" },
{ label: "药品", path: "/study/imp/inventory" }, { label: "药品库存", path: "/study/imp/inventory", icon: Management, color: "#52c41a" },
{ label: "费用", path: "/study/finance" }, { label: "费用治理", path: "/study/finance", icon: Money, color: "#eb2f96" },
{ label: "FAQ", path: "/study/faq" }, { label: "项目知识库", path: "/study/faq", icon: Collection, color: "#fa8c16" },
]; ];
const go = (path: string) => router.push(path); const go = (path: string) => router.push(path);
</script> </script>
<style scoped> <style scoped>
.actions { .quick-actions-section {
background-color: #fff;
border-radius: var(--ctms-radius);
padding: 24px;
box-shadow: var(--ctms-shadow-sm);
}
.section-header {
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 20px;
}
.header-icon {
font-size: 18px;
color: var(--ctms-primary);
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.action-item {
display: flex;
align-items: center;
padding: 12px 16px;
background-color: #fafafa;
border: 1px solid var(--ctms-border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.action-item:hover {
background-color: #fff;
border-color: var(--ctms-primary-hover);
box-shadow: var(--ctms-shadow-sm);
transform: translateY(-2px);
}
.action-icon-wrapper {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-right: 12px;
}
.action-label {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
flex: 1;
}
.action-arrow {
font-size: 14px;
color: var(--ctms-text-disabled);
opacity: 0;
transition: all 0.3s ease;
transform: translateX(-10px);
}
.action-item:hover .action-arrow {
opacity: 1;
transform: translateX(0);
} }
</style> </style>
+56 -8
View File
@@ -1,14 +1,18 @@
<template> <template>
<el-dropdown> <el-dropdown trigger="click" class="study-selector-dropdown">
<span class="el-dropdown-link"> <div class="selector-trigger">
<el-tag size="small" type="info"> <el-icon class="trigger-icon"><OfficeBuilding /></el-icon>
{{ study.currentStudy?.name || "选择项目" }} <span class="current-study-name">{{ study.currentStudy?.name || "选择项目" }}</span>
</el-tag> <el-icon class="arrow-icon"><ArrowDown /></el-icon>
</span> </div>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="goSelect">切换项目</el-dropdown-item> <el-dropdown-item @click="goSelect">
<el-dropdown-item @click="clearStudy">清除当前项目</el-dropdown-item> <el-icon><Switch /></el-icon>切换项目
</el-dropdown-item>
<el-dropdown-item divided @click="clearStudy" class="danger-item">
<el-icon><Close /></el-icon>退出当前项目
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@@ -17,6 +21,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { OfficeBuilding, ArrowDown, Switch, Close } from "@element-plus/icons-vue";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
@@ -30,3 +35,46 @@ const clearStudy = () => {
router.push("/studies"); router.push("/studies");
}; };
</script> </script>
<style scoped>
.selector-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background-color: #fafafa;
border: 1px solid var(--ctms-border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.selector-trigger:hover {
background-color: var(--ctms-primary-light);
border-color: var(--ctms-primary-hover);
}
.trigger-icon {
color: var(--ctms-primary);
font-size: 16px;
}
.current-study-name {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow-icon {
font-size: 12px;
color: var(--ctms-text-secondary);
}
.danger-item {
color: var(--ctms-danger);
}
</style>
+132 -66
View File
@@ -1,50 +1,89 @@
<template> <template>
<div class="study-home" v-if="study.currentStudy"> <div class="study-home-container" v-if="study.currentStudy">
<div class="header"> <!-- 概览头部 -->
<div> <div class="home-header">
<h2>项目概览</h2> <div class="header-content">
<p class="subtitle"> <h1 class="welcome-title">项目概览</h1>
{{ study.currentStudy.name }}{{ study.currentStudy.code }} <p class="study-info">
<span class="study-name">{{ study.currentStudy.name }}</span>
<span class="study-divider">/</span>
<span class="study-code">{{ study.currentStudy.code }}</span>
</p> </p>
</div> </div>
<div class="header-meta">
<el-tag effect="plain" class="role-tag">我的角色: {{ auth.user?.role }}</el-tag>
</div>
</div> </div>
<el-row :gutter="16" class="kpi-row"> <!-- 数据指标网格 -->
<el-col :span="6"> <div class="stats-grid">
<div class="stats-item">
<KpiCard <KpiCard
title="节点完成率" title="节点完成率"
:value="milestoneCompletionRate" :value="progress?.milestones_done ?? 0"
:subtext="milestoneCompletionText" :unit="` / ${progress?.milestones_total ?? 0}`"
:subtext="milestoneCompletionRate"
:loading="loading.progress" :loading="loading.progress"
:icon="List"
/> />
</el-col> </div>
<el-col :span="6"> <div class="stats-item">
<KpiCard title="逾期 AE" :value="overdueAes" :loading="loading.overdueAes" /> <KpiCard
</el-col> title="逾期 AE"
<el-col :span="6"> :value="overdueAes"
<KpiCard title="逾期数据问题" :value="overdueQueries" :loading="loading.overdueQueries" /> unit="例"
</el-col> subtext="需尽快跟进处理"
<el-col :span="6"> :loading="loading.overdueAes"
:icon="Warning"
/>
</div>
<div class="stats-item">
<KpiCard
title="逾期数据问题"
:value="overdueQueries"
unit="项"
subtext="严禁滞留超过 48h"
:loading="loading.overdueQueries"
:icon="QuestionFilled"
/>
</div>
<div class="stats-item">
<KpiCard <KpiCard
title="费用总额" title="费用总额"
:value="financeSummary?.total_amount ?? 0" :value="formatAmount(financeSummary?.total_amount ?? 0)"
:subtext="`已支付:${financeSummary?.paid_amount ?? 0}`" :subtext="`累计已支付:¥${formatAmount(financeSummary?.paid_amount ?? 0)}`"
:loading="loading.finance" :loading="loading.finance"
:icon="Money"
/> />
</el-col> </div>
</el-row> </div>
<!-- 通知/警告 -->
<el-alert <el-alert
type="info" v-if="overdueAes > 0 || overdueQueries > 0"
title="任务模块已并入伦理与启动管理,请在节点下处理相关事项" type="warning"
title="存在逾期未处理事项,请及时关注项目的合规性与进度。"
show-icon show-icon
class="section-card" :closable="false"
class="home-alert"
/>
<el-alert
v-else
type="success"
title="项目运行状态良好,所有关键指标均在可控范围内。"
show-icon
:closable="false"
class="home-alert"
/> />
<QuickActions class="mt-16" /> <!-- 快捷模块 -->
<div class="quick-nav-section">
<QuickActions />
</div>
</div> </div>
<div v-else class="study-home">
<el-alert type="info" title="请先选择一个项目" /> <div v-else class="empty-state-container">
<el-empty description="请先在顶部或项目列表选择一个当前项目" />
</div> </div>
</template> </template>
@@ -55,6 +94,7 @@ import { useStudyStore } from "../store/study";
import { fetchFinanceSummary, fetchOverdueAesCount, fetchOverdueQueriesCount, fetchProgress } from "../api/dashboard"; import { fetchFinanceSummary, fetchOverdueAesCount, fetchOverdueQueriesCount, fetchProgress } from "../api/dashboard";
import KpiCard from "../components/KpiCard.vue"; import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue"; import QuickActions from "../components/QuickActions.vue";
import { List, Warning, QuestionFilled, Money } from "@element-plus/icons-vue";
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
@@ -73,19 +113,13 @@ const loading = ref({
const milestoneCompletionRate = computed(() => { const milestoneCompletionRate = computed(() => {
const total = progress.value?.milestones_total ?? 0; const total = progress.value?.milestones_total ?? 0;
if (!total) return "-"; if (!total) return "暂无节点";
return formatPercent((progress.value?.milestones_done ?? 0) / total); const rate = (progress.value?.milestones_done ?? 0) / total;
return `总体进度: ${(rate * 100).toFixed(0)}%`;
}); });
const milestoneCompletionText = computed(() => { const formatAmount = (val: number) => {
const total = progress.value?.milestones_total ?? 0; return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2 }).format(val);
const done = progress.value?.milestones_done ?? 0;
return total ? `已完成 ${done}/${total}` : "暂无节点数据";
});
const formatPercent = (v?: number | null) => {
if (v === undefined || v === null) return "-";
return `${(v * 100).toFixed(0)}%`;
}; };
const loadData = async () => { const loadData = async () => {
@@ -105,18 +139,10 @@ const loadData = async () => {
fetchOverdueQueriesCount(studyId), fetchOverdueQueriesCount(studyId),
]); ]);
if (pRes.status === "fulfilled") { if (pRes.status === "fulfilled") progress.value = pRes.value.data;
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 (fRes.status === "fulfilled") { if (dqRes.status === "fulfilled") overdueQueries.value = dqRes.value.data?.total ?? 0;
financeSummary.value = fRes.value.data;
}
if (aesRes.status === "fulfilled") {
overdueAes.value = aesRes.value.data?.total ?? 0;
}
if (dqRes.status === "fulfilled") {
overdueQueries.value = dqRes.value.data?.total ?? 0;
}
} finally { } finally {
loading.value.progress = false; loading.value.progress = false;
loading.value.finance = false; loading.value.finance = false;
@@ -131,29 +157,69 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.study-home { .study-home-container {
padding: 16px; display: flex;
flex-direction: column;
gap: 24px;
} }
.header {
.home-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-end;
margin-bottom: 16px; padding-bottom: 8px;
} }
.subtitle {
color: #909399; .welcome-title {
font-size: 28px;
font-weight: 700;
color: var(--ctms-text-main);
margin: 0; margin: 0;
} }
.kpi-row {
margin-bottom: 16px; .study-info {
margin: 8px 0 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
color: var(--ctms-text-secondary);
} }
.section-card {
margin-top: 16px; .study-divider {
opacity: 0.3;
} }
.mb-8 {
margin-bottom: 8px; .role-tag {
background-color: #fff;
border-color: var(--ctms-primary-hover);
color: var(--ctms-primary);
font-weight: 500;
} }
.mt-16 {
margin-top: 16px; .stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.home-alert {
border: none;
border-left: 4px solid;
border-radius: 4px;
}
.quick-nav-section {
margin-top: 8px;
}
.empty-state-container {
padding-top: 100px;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
} }
</style> </style>
+199 -78
View File
@@ -1,71 +1,90 @@
<template> <template>
<div class="page"> <div class="workbench-container">
<el-card class="mb-12"> <!-- 头部欢迎区域 -->
<div class="header"> <div class="workbench-header">
<div> <div class="header-content">
<h2>我的工作台</h2> <h1 class="welcome-title">我的工作台</h1>
<div class="sub">基于当前角色为你聚合最需要处理的事项</div> <p class="welcome-subtitle">欢迎回来这是基于您的角色 <strong>{{ roleLabel }}</strong> 聚合的待办事项</p>
</div>
<el-tag type="info">{{ roleLabel }}</el-tag>
</div> </div>
<div v-if="!currentStudyId" class="empty-tip"> <div class="header-actions">
请选择一个项目后查看工作台内容 <el-tag effect="dark" class="role-badge">{{ roleLabel }}</el-tag>
</div> </div>
</el-card> </div>
<!-- 提示未选择项目 -->
<el-alert
v-if="!currentStudyId"
title="请先在顶部导航或项目列表中选择一个项目,以开启完整的工作台功能。"
type="warning"
show-icon
:closable="false"
class="study-alert"
/>
<template v-if="currentStudyId"> <template v-if="currentStudyId">
<el-row :gutter="12"> <!-- 核心看板层 -->
<el-col :span="12"> <div class="stats-row">
<div class="stats-col">
<SectionCard <SectionCard
title="今日待办" title="今日待办"
:items="todayList" :items="todayList"
:loading="loading" :loading="loading"
empty-text="暂无需要你处理的事项" empty-text="今天暂时没有需要紧急处理的业务事项"
@more="goMore(todayMorePath)" @more="goMore(todayMorePath)"
/> />
</el-col> </div>
<el-col :span="12"> <div class="stats-col">
<SectionCard <SectionCard
title="已逾期" title="已逾期事项"
:items="overdueList" :items="overdueList"
:loading="loading" :loading="loading"
empty-text="暂无需要你处理的事项" empty-text="太棒了目前没有任何逾期待处理的事项"
@more="goMore(overdueMorePath)" @more="goMore(overdueMorePath)"
/> />
</el-col> </div>
</el-row> </div>
<el-row :gutter="12" class="mt-12"> <!-- 次级看板与快捷入口 -->
<el-col :span="12"> <div class="secondary-row">
<div class="action-col">
<SectionCard <SectionCard
title="需要我处理的事项" title="需要我处理的事项"
:items="actionList" :items="actionList"
:loading="loading" :loading="loading"
empty-text="暂无需要处理的事项" empty-text="暂无需要处理的事项"
@more="goMore(actionMorePath)" @more="goMore(actionMorePath)"
/> />
</el-col> </div>
<el-col :span="12"> <div class="quick-col">
<el-card shadow="never" class="section-card"> <div class="quick-entry-card">
<div class="section-header"> <div class="entry-header">
<span>快捷入口</span> <span class="entry-title">快捷入口</span>
<el-button type="primary" link @click="goMore('/study/home')">进入项目</el-button> <el-button type="primary" link @click="goMore('/study/home')" class="entry-more">
</div> 进入项目 <el-icon class="el-icon--right"><Right /></el-icon>
<div class="quick-list">
<el-button v-for="qa in quickActions" :key="qa.path" type="primary" plain size="small" @click="goMore(qa.path)">
{{ qa.label }}
</el-button> </el-button>
</div> </div>
<div v-if="!quickActions.length" class="empty">{{ emptyText }}</div> <div class="quick-grid">
</el-card> <div
</el-col> v-for="qa in quickActions"
</el-row> :key="qa.path"
class="quick-item"
@click="goMore(qa.path)"
>
<div class="quick-icon-box">
<el-icon><component :is="qa.icon" /></el-icon>
</div>
<span class="quick-label">{{ qa.label }}</span>
</div>
</div>
<div v-if="!quickActions.length" class="empty-quick">{{ emptyText }}</div>
</div>
</div>
</div>
<el-row v-if="showPersonalTodo" class="mt-12"> <!-- 个人备忘层 -->
<el-col :span="24"> <div v-if="showPersonalTodo" class="todo-row">
<PersonalTodo :storage-key="todoStorageKey" /> <PersonalTodo :storage-key="todoStorageKey" />
</el-col> </div>
</el-row>
</template> </template>
</div> </div>
</template> </template>
@@ -79,6 +98,7 @@ import { useStudyStore } from "../../store/study";
import { fetchAes } from "../../api/aes"; import { fetchAes } from "../../api/aes";
import { fetchFinanceItems } from "../../api/finance"; import { fetchFinanceItems } from "../../api/finance";
import { fetchImpTransactions } from "../../api/impTransactions"; import { fetchImpTransactions } from "../../api/impTransactions";
import { Right, Timer, UserFilled, Warning, Money, Collection, Management } from "@element-plus/icons-vue";
interface WorkItem { interface WorkItem {
title: string; title: string;
@@ -112,18 +132,18 @@ const quickActions = computed(() => {
if (!currentStudyId.value) return []; if (!currentStudyId.value) return [];
if (role.value === "CRA") if (role.value === "CRA")
return [ return [
{ label: "伦理启动", path: "/study/milestones" }, { label: "伦理启动", path: "/study/milestones", icon: Timer },
{ label: "AE 列表", path: "/study/aes" }, { label: "AE 列表", path: "/study/aes", icon: Warning },
]; ];
if (role.value === "PM") if (role.value === "PM")
return [ return [
{ label: "伦理启动", path: "/study/milestones" }, { label: "伦理启动", path: "/study/milestones", icon: Timer },
{ label: "费用审核", path: "/study/finance" }, { label: "费用审核", path: "/study/finance", icon: Money },
{ label: "公告维护", path: "/study/faq" }, { label: "公告管理", path: "/study/faq", icon: Collection },
]; ];
if (role.value === "PV") return [{ label: "AE 列表", path: "/study/aes" }]; if (role.value === "PV") return [{ label: "AE 列表", path: "/study/aes", icon: Warning }];
if (role.value === "IMP") return [{ label: "药品台账", path: "/study/imp/transactions" }]; if (role.value === "IMP") return [{ label: "药品台账", path: "/study/imp/transactions", icon: Management }];
return [{ label: "伦理启动", path: "/study/milestones" }]; return [{ label: "伦理启动", path: "/study/milestones", icon: Timer }];
}); });
const todayMorePath = computed(() => (role.value === "IMP" ? "/study/imp/transactions" : "/study/aes")); const todayMorePath = computed(() => (role.value === "IMP" ? "/study/imp/transactions" : "/study/aes"));
@@ -176,7 +196,8 @@ const loadData = async () => {
const aes = aesRes.status === "fulfilled" ? (aesRes.value.data.items || aesRes.value.data || []) : []; const aes = aesRes.status === "fulfilled" ? (aesRes.value.data.items || aesRes.value.data || []) : [];
const finances = const finances =
financeRes.status === "fulfilled" ? (financeRes.value.data.items || financeRes.value.data || []) : []; financeRes.status === "fulfilled" ? (financeRes.value.data.items || financeRes.value.data || []) : [];
const impTx = impRes.status === "fulfilled" ? (impRes.value.data.items || impRes.value.data || []) : []; const impData = impRes.status === "fulfilled" ? (impRes.value as any).data : [];
const impTx = Array.isArray(impData) ? impData : (impData?.items || []);
buildSections(aes, finances, impTx); buildSections(aes, finances, impTx);
} catch (e: any) { } catch (e: any) {
@@ -193,7 +214,6 @@ const buildSections = (aes: any[], finances: any[], impTx: any[]) => {
const openAes = aes.filter((a) => a.status !== "CLOSED"); const openAes = aes.filter((a) => a.status !== "CLOSED");
const financePending = finances.filter((f) => f.status !== "PAID"); const financePending = finances.filter((f) => f.status !== "PAID");
const overdueAes = openAes.filter((a) => isOverdue(a.expected_resolution_date || a.updated_at)); const overdueAes = openAes.filter((a) => isOverdue(a.expected_resolution_date || a.updated_at));
if (role.value === "CRA") { if (role.value === "CRA") {
@@ -231,43 +251,144 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.page { .workbench-container {
padding: 16px;
}
.header {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; gap: 24px;
} }
.sub {
color: #666; .workbench-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.welcome-title {
font-size: 28px;
font-weight: 700;
color: var(--ctms-text-main);
margin: 0;
}
.welcome-subtitle {
margin: 8px 0 0;
font-size: 15px;
color: var(--ctms-text-secondary);
}
.role-badge {
background-color: var(--ctms-primary);
border: none;
font-weight: 600;
padding: 0 12px;
height: 28px;
line-height: 28px;
}
.stats-row, .secondary-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.todo-row {
margin-top: 8px;
}
.study-alert {
border: none;
border-left: 4px solid var(--ctms-warning);
background-color: #fffbe6;
}
/* 快捷入口卡片样式 */
.quick-entry-card {
background-color: #fff;
border: 1px solid var(--ctms-border-color);
border-radius: 8px;
height: 100%;
display: flex;
flex-direction: column;
}
.entry-header {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--ctms-border-color);
background-color: #fafafa;
}
.entry-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
}
.entry-more {
font-size: 13px; font-size: 13px;
} }
.empty-tip {
padding: 12px 0; .quick-grid {
color: #888; padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 16px;
} }
.section-card {
min-height: 220px; .quick-item {
}
.section-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; align-items: center;
margin-bottom: 8px; gap: 10px;
cursor: pointer;
transition: all 0.3s ease;
padding: 12px;
border-radius: 8px;
} }
.quick-list {
.quick-item:hover {
background-color: var(--ctms-primary-light);
transform: translateY(-2px);
}
.quick-icon-box {
width: 40px;
height: 40px;
background-color: #fff;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
display: flex; display: flex;
gap: 8px; align-items: center;
flex-wrap: wrap; justify-content: center;
font-size: 20px;
color: var(--ctms-primary);
box-shadow: var(--ctms-shadow-sm);
transition: all 0.3s ease;
} }
.empty {
color: #888; .quick-item:hover .quick-icon-box {
border-color: var(--ctms-primary);
background-color: var(--ctms-primary);
color: #fff;
} }
.mt-12 {
margin-top: 12px; .quick-label {
font-size: 13px;
font-weight: 500;
color: var(--ctms-text-regular);
} }
.mb-12 {
margin-bottom: 12px; .empty-quick {
padding: 40px;
text-align: center;
color: var(--ctms-text-disabled);
}
@media (max-width: 992px) {
.stats-row, .secondary-row {
grid-template-columns: 1fr;
}
} }
</style> </style>
@@ -1,24 +1,65 @@
<template> <template>
<el-card shadow="never" class="todo-card"> <el-card shadow="never" class="todo-card">
<div class="section-header"> <template #header>
<span>个人待办本地</span> <div class="section-header">
</div> <div class="header-left">
<div class="input-row"> <el-icon class="header-icon"><Notebook /></el-icon>
<el-input v-model="input" placeholder="记录一个备忘..." @keyup.enter.native="add" /> <span class="header-title">个人备忘 (本地存储)</span>
<el-button type="primary" @click="add">添加</el-button> </div>
</div> <el-tag size="small" effect="plain" type="info" class="count-tag">
<div v-if="todos.length" class="todo-list"> {{ todos.length }} 条记录
<div v-for="item in todos" :key="item.id" class="todo-item"> </el-tag>
<el-checkbox v-model="item.done" @change="save">{{ item.text }}</el-checkbox> </div>
<el-button link type="danger" size="small" @click="remove(item.id)">删除</el-button> </template>
<div class="todo-body">
<div class="input-section">
<el-input
v-model="input"
placeholder="有什么需要记录的事项吗?(回车添加)"
class="todo-input"
@keyup.enter="add"
>
<template #prefix>
<el-icon><EditPen /></el-icon>
</template>
</el-input>
<el-button type="primary" :icon="Plus" @click="add" class="add-btn">
添加
</el-button>
</div>
<div v-if="todos.length" class="todo-list">
<transition-group name="todo-list">
<div
v-for="item in todos"
:key="item.id"
class="todo-item"
:class="{ 'is-done': item.done }"
>
<el-checkbox v-model="item.done" @change="save" class="todo-checkbox">
<span class="todo-text">{{ item.text }}</span>
</el-checkbox>
<el-button
link
type="danger"
:icon="Delete"
@click="remove(item.id)"
class="delete-btn"
></el-button>
</div>
</transition-group>
</div>
<div v-else class="empty-state">
<el-empty description="暂无待办事项,记录一些备忘吧" :image-size="60" />
</div> </div>
</div> </div>
<div v-else class="empty">暂无待办可添加你的个人备忘</div>
</el-card> </el-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from "vue"; import { onMounted, ref, watch } from "vue";
import { Notebook, EditPen, Plus, Delete } from "@element-plus/icons-vue";
const props = defineProps<{ storageKey: string | undefined }>(); const props = defineProps<{ storageKey: string | undefined }>();
@@ -78,31 +119,131 @@ onMounted(() => {
<style scoped> <style scoped>
.todo-card { .todo-card {
min-height: 200px; border: 1px solid var(--ctms-border-color);
border-radius: 8px;
} }
:deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid var(--ctms-border-color);
background-color: #fafafa;
}
.section-header { .section-header {
font-weight: 600;
margin-bottom: 8px;
}
.input-row {
display: flex; display: flex;
gap: 8px; justify-content: space-between;
margin-bottom: 8px; align-items: center;
} }
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
font-size: 18px;
color: var(--ctms-primary);
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
}
.count-tag {
border-radius: 12px;
}
.todo-body {
padding: 20px;
}
.input-section {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.todo-input :deep(.el-input__wrapper) {
box-shadow: none;
border: 1px solid var(--ctms-border-color);
transition: all 0.3s ease;
}
.todo-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--ctms-primary);
}
.add-btn {
padding-left: 20px;
padding-right: 20px;
}
.todo-list { .todo-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
} }
.todo-item { .todo-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 6px 8px; padding: 12px 16px;
border: 1px solid #ebeef5; background-color: #fafafa;
border: 1px solid var(--ctms-border-color);
border-radius: 6px; border-radius: 6px;
transition: all 0.2s ease;
} }
.empty {
color: #888; .todo-item:hover {
background-color: #fff;
border-color: var(--ctms-primary-hover);
box-shadow: var(--ctms-shadow-sm);
}
.todo-item.is-done {
opacity: 0.6;
}
.todo-item.is-done .todo-text {
text-decoration: line-through;
color: var(--ctms-text-disabled);
}
.todo-checkbox {
flex: 1;
}
.todo-text {
font-size: 14px;
color: var(--ctms-text-main);
transition: all 0.2s ease;
}
.delete-btn {
opacity: 0;
transition: all 0.2s ease;
}
.todo-item:hover .delete-btn {
opacity: 1;
}
/* 列表动画 */
.todo-list-enter-active,
.todo-list-leave-active {
transition: all 0.3s ease;
}
.todo-list-enter-from,
.todo-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.empty-state {
padding: 20px 0;
} }
</style> </style>
@@ -1,28 +1,52 @@
<template> <template>
<el-card shadow="never" class="section-card"> <el-card shadow="never" class="section-card">
<div class="section-header"> <template #header>
<span>{{ title }}</span> <div class="section-header">
<el-button v-if="!loading" type="primary" link @click="$emit('more')">查看更多</el-button> <span class="section-title">{{ title }}</span>
</div> <el-button v-if="!loading" type="primary" link @click="$emit('more')" class="more-btn">
<el-skeleton :loading="loading" animated :rows="3"> 查看更多 <el-icon class="el-icon--right"><ArrowRight /></el-icon>
<template #default> </el-button>
<div v-if="items && items.length" class="list"> </div>
<div v-for="item in items.slice(0, 5)" :key="item.title + item.path" class="row" @click="go(item.path)"> </template>
<div class="title">{{ item.title }}</div>
<div class="meta"> <div class="section-body">
<el-tag size="small" :type="item.overdue ? 'danger' : 'info'">{{ item.status || "待处理" }}</el-tag> <el-skeleton :loading="loading" animated :rows="3">
<span class="sub">{{ item.subtitle }}</span> <template #default>
<div v-if="items && items.length" class="list-container">
<div
v-for="item in items.slice(0, 5)"
:key="item.title + item.path"
class="list-item"
@click="go(item.path)"
>
<div class="item-main">
<div class="item-title">{{ item.title }}</div>
<div class="item-subtitle">{{ item.subtitle }}</div>
</div>
<div class="item-meta">
<el-tag
size="small"
:type="item.overdue ? 'danger' : 'info'"
:effect="item.overdue ? 'dark' : 'light'"
class="status-tag"
>
{{ item.status || "待处理" }}
</el-tag>
</div>
</div> </div>
</div> </div>
</div> <div v-else class="empty-state">
<div v-else class="empty">{{ emptyText }}</div> <el-empty :description="emptyText" :image-size="60" />
</template> </div>
</el-skeleton> </template>
</el-skeleton>
</div>
</el-card> </el-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ArrowRight } from "@element-plus/icons-vue";
defineProps<{ defineProps<{
title: string; title: string;
@@ -42,43 +66,92 @@ const go = (path: string) => {
<style scoped> <style scoped>
.section-card { .section-card {
min-height: 220px; border: 1px solid var(--ctms-border-color);
border-radius: 8px;
height: 100%;
display: flex;
flex-direction: column;
} }
.section-header {
:deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid var(--ctms-border-color);
background-color: #fafafa;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
}
.more-btn {
font-size: 13px;
color: var(--ctms-primary);
}
.section-body {
flex: 1;
padding: 0;
}
.list-container {
display: flex;
flex-direction: column;
}
.list-item {
padding: 16px 20px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 8px;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
padding: 8px;
border: 1px solid #ebeef5;
border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--ctms-border-color);
} }
.row:hover {
background: #f5f7fa; .list-item:last-child {
border-bottom: none;
} }
.title {
font-weight: 600; .list-item:hover {
background-color: var(--ctms-primary-light);
} }
.meta {
margin-top: 4px; .item-main {
display: flex; flex: 1;
align-items: center; overflow: hidden;
gap: 8px; margin-right: 16px;
color: #666; }
.item-title {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-subtitle {
font-size: 12px; font-size: 12px;
color: var(--ctms-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.sub {
color: #888; .status-tag {
border-radius: 4px;
} }
.empty {
color: #888; .empty-state {
padding: 40px 0;
}
:deep(.el-empty__description) {
margin-top: 8px;
color: var(--ctms-text-disabled);
} }
</style> </style>