前端UI界面美化-2
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user