You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

967 lines
27 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div v-loading="pageLoading" class="app-container dashboard">
<!-- A. 欢迎区 -->
<section class="welcome-banner">
<div class="welcome-banner__main">
<div class="welcome-banner__greet">
<span class="welcome-banner__time">{{ greetingText }}</span>
<span class="welcome-banner__name">{{ displayName }}</span>
</div>
<p class="welcome-banner__meta">
<span v-if="displayDept"><svg-icon icon-class="tree" class="meta-icon" />{{ displayDept }}</span>
<span class="welcome-banner__date"><svg-icon icon-class="date" class="meta-icon" />{{ todayText }}</span>
</p>
<p class="welcome-banner__tip">{{ welcomeTip }}</p>
</div>
<div class="welcome-banner__actions">
<el-button type="primary" plain icon="Refresh" @click="handleRefresh">刷新</el-button>
<el-badge :value="unreadMessageCount" :hidden="unreadMessageCount === 0" :max="99">
<el-button plain icon="Bell" @click="focusMessages">消息</el-button>
</el-badge>
</div>
</section>
<!-- B. 个人概览卡片 -->
<section class="summary-row">
<div
v-for="card in summaryCards"
:key="card.key"
class="summary-card"
:class="`summary-card--${card.theme}`"
@click="onSummaryClick(card)"
>
<div class="summary-card__icon">
<el-icon :size="22"><component :is="card.icon" /></el-icon>
</div>
<div class="summary-card__body">
<div class="summary-card__value">{{ card.displayValue }}</div>
<div class="summary-card__label">{{ card.label }}</div>
</div>
</div>
</section>
<el-row :gutter="16" class="dashboard-main">
<!-- 左侧主栏 -->
<el-col :xs="24" :lg="16">
<!-- C. 我的待办 -->
<el-card shadow="hover" class="block-card">
<template #header>
<div class="block-card__header">
<span class="block-card__title">
<el-icon><Tickets /></el-icon>
我的待办
</span>
<el-link type="primary" :underline="false" @click="openMenu('workflow/task/taskWaiting')">查看全部</el-link>
</div>
</template>
<div v-if="taskWaitList.length" class="task-list">
<div
v-for="item in taskWaitList"
:key="item.id"
class="task-list__item"
@click="onTaskClick(item)"
>
<div class="task-list__title">{{ item.businessTitle }}</div>
<div class="task-list__meta">
<el-tag size="small" effect="plain">{{ item.flowName }}</el-tag>
<span class="task-list__node">{{ item.nodeName }}</span>
<span v-if="taskCreateByName(item)" class="task-list__user">发起人:{{ taskCreateByName(item) }}</span>
</div>
<div class="task-list__time">{{ item.createTime }}</div>
</div>
</div>
<el-empty v-else description="暂无待办,辛苦了" :image-size="72" />
</el-card>
<!-- E. 我发起的流程 -->
<el-card shadow="hover" class="block-card block-card--last">
<template #header>
<div class="block-card__header">
<span class="block-card__title">
<el-icon><Document /></el-icon>
我发起的(进行中)
</span>
<el-link type="primary" :underline="false" @click="openMenu('workflow/task/myDocument')">查看全部</el-link>
</div>
</template>
<el-table v-loading="processLoading" :data="myProcessList" stripe size="small" class="compact-table" @row-click="onProcessRowClick">
<el-table-column prop="businessTitle" label="业务标题" min-width="160" show-overflow-tooltip />
<el-table-column prop="flowName" label="流程" width="120" show-overflow-tooltip />
<el-table-column prop="nodeName" label="当前节点" width="110" show-overflow-tooltip />
<el-table-column prop="flowStatus" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.statusType" size="small">{{ row.flowStatusLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="发起时间" width="150" align="center" />
</el-table>
</el-card>
</el-col>
<!-- 右侧辅栏 -->
<el-col :xs="24" :lg="8">
<!-- D. 公告与消息 -->
<el-card shadow="hover" class="block-card">
<template #header>
<div class="block-card__header">
<span class="block-card__title">
<el-icon><Message /></el-icon>
公告与消息
</span>
</div>
</template>
<el-tabs v-model="noticeTab" class="notice-tabs" :class="{ 'notice-tabs--calendar': noticeTab === 'calendar' }">
<el-tab-pane label="系统公告" name="notice">
<ul v-if="noticeList.length" class="notice-feed">
<li
v-for="n in noticeList"
:key="n.noticeId"
class="notice-feed__item"
@click="onNoticeClick(n)"
>
<el-tag size="small" :type="n.tagType">{{ n.noticeTypeLabel }}</el-tag>
<span class="notice-feed__title">{{ n.noticeTitle }}</span>
<span class="notice-feed__time">{{ n.createTime }}</span>
</li>
</ul>
<el-empty v-else description="暂无公告" :image-size="64" />
</el-tab-pane>
<el-tab-pane name="message">
<template #label>
<span>站内消息</span>
<el-badge v-if="unreadMessageCount" :value="unreadMessageCount" class="tab-badge" />
</template>
<ul v-if="messageList.length" class="notice-feed">
<li
v-for="(m, k) in messageList"
:key="k"
class="notice-feed__item"
:class="{ 'is-unread': !m.read }"
@click="onMessageClick(k)"
>
<span v-if="!m.read" class="notice-feed__dot" />
<span class="notice-feed__title">{{ m.message }}</span>
<span class="notice-feed__time">{{ m.time }}</span>
</li>
</ul>
<el-empty v-else description="暂无消息" :image-size="64" />
</el-tab-pane>
<el-tab-pane label="工作日历" name="calendar">
<WorkCalendar
ref="workCalendarRef"
:active="noticeTab === 'calendar'"
:pending-tasks="calendarPendingTasks"
@task-click="onCalendarTaskClick"
/>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- F. 快捷入口 -->
<el-card shadow="hover" class="block-card block-card--last">
<template #header>
<div class="block-card__header">
<span class="block-card__title">
<el-icon><Grid /></el-icon>
快捷入口
</span>
</div>
</template>
<div class="shortcut-grid">
<div
v-for="s in visibleShortcuts"
:key="s.key"
class="shortcut-grid__item"
@click="onShortcutClick(s)"
>
<div class="shortcut-grid__icon" :style="{ background: s.bg }">
<el-icon :size="20"><component :is="s.icon" /></el-icon>
</div>
<span class="shortcut-grid__label">{{ s.label }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup name="Index" lang="ts">
import {
Briefcase,
Calendar,
Document,
EditPen,
Grid,
Message,
Notebook,
Reading,
Tickets,
Timer,
User
} from '@element-plus/icons-vue';
import { pageByTaskWait, pageByTaskCopy } from '@/api/workflow/task';
import { FlowTaskVO } from '@/api/workflow/task/types';
import { pageByCurrent } from '@/api/workflow/instance';
import { FlowInstanceVO } from '@/api/workflow/instance/types';
import workflowCommon from '@/api/workflow/workflowCommon';
import { RouterJumpVo } from '@/api/workflow/workflowCommon/types';
import { listNotice } from '@/api/system/notice';
import { NoticeVO } from '@/api/system/notice/types';
import { getUserProfile } from '@/api/system/user';
import { listContractInfo } from '@/api/oa/erp/contractInfo';
import { listTimesheetInfo } from '@/api/oa/erp/timesheetInfo';
import { TimesheetInfoVO } from '@/api/oa/erp/timesheetInfo/types';
import { useUserStore } from '@/store/modules/user';
import { useNoticeStore } from '@/store/modules/notice';
import { resolveMenuPath } from '@/utils/resolveMenuPath';
import WorkCalendar from '@/components/WorkCalendar/index.vue';
import type { CalendarPendingTask } from '@/components/WorkCalendar/workCalendar.types';
import dayjs from 'dayjs';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const userStore = useUserStore();
const noticeStore = useNoticeStore();
const { wf_business_status, sys_notice_type, timesheet_status } = toRefs<any>(
proxy?.useDict('wf_business_status', 'sys_notice_type', 'timesheet_status')
);
const RUNNING_FLOW_STATUS = ['waiting', 'back', 'draft'];
const pageLoading = ref(false);
const processLoading = ref(false);
const profileDept = ref('');
const summary = reactive({
taskWaitCount: 0,
taskCopyCount: 0,
myProcessRunningCount: 0,
timesheetWeekStatus: 'NOT_FILLED',
timesheetWeekLabel: '未填报',
contractCount: 0
});
const taskWaitList = ref<FlowTaskVO[]>([]);
const myProcessList = ref<
{
id: string | number;
businessId: string;
businessTitle: string;
flowName: string;
nodeName: string;
flowStatus: string;
flowStatusLabel: string;
statusType: 'success' | 'warning' | 'info' | 'danger' | 'primary';
createTime: string;
formCustom: string;
formPath: string;
}[]
>([]);
const noticeList = ref<
{
noticeId: number;
noticeTitle: string;
noticeTypeLabel: string;
tagType: 'success' | 'warning' | 'info' | 'danger' | 'primary';
createTime: string;
}[]
>([]);
const messageList = computed(() => noticeStore.state.notices);
const shortcutDefs = [
{ key: 'taskWait', label: '我的待办', icon: Tickets, bg: 'linear-gradient(135deg, #409eff 0%, #66b1ff 100%)', menuComponent: 'workflow/task/taskWaiting' },
{ key: 'myDoc', label: '我发起的', icon: Document, bg: 'linear-gradient(135deg, #67c23a 0%, #85ce61 100%)', menuComponent: 'workflow/task/myDocument' },
{ key: 'profile', label: '个人中心', icon: User, bg: 'linear-gradient(135deg, #909399 0%, #b1b3b8 100%)', menuComponent: 'user/profile' },
{ key: 'timesheet', label: '工时填报', icon: Timer, bg: 'linear-gradient(135deg, #e6a23c 0%, #ebb563 100%)', menuComponent: 'oa/erp/timesheetInfo/index' },
{ key: 'report', label: '项目周报', icon: EditPen, bg: 'linear-gradient(135deg, #f56c6c 0%, #f78989 100%)', menuComponent: 'oa/erp/projectReport/index' },
{ key: 'project', label: '项目信息', icon: Briefcase, bg: 'linear-gradient(135deg, #409eff 0%, #53a8ff 100%)', menuComponent: 'oa/erp/projectInfo/index' },
{ key: 'notice', label: '系统公告', icon: Reading, bg: 'linear-gradient(135deg, #00bcd4 0%, #26c6da 100%)', menuComponent: 'system/notice/index' }
];
const visibleShortcuts = computed(() =>
shortcutDefs.filter((s) => resolveMenuPath(s.menuComponent) != null)
);
const noticeTab = ref('notice');
const workCalendarRef = ref<InstanceType<typeof WorkCalendar>>();
const calendarPendingTasks = computed<CalendarPendingTask[]>(() =>
taskWaitList.value.map((t) => ({
id: t.id,
businessId: t.businessId,
businessTitle: t.businessTitle || '—',
createTime: t.createTime ? String(t.createTime) : undefined,
flowName: t.flowName,
formCustom: t.formCustom,
formPath: t.formPath
}))
);
const displayName = computed(() => userStore.nickname || '用户');
const displayDept = computed(() => {
const user = profileDept.value;
return user || '';
});
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const todayText = computed(() => {
const d = dayjs();
return `${d.format('YYYY年MM月DD日')} ${weekDays[d.day()]}`;
});
const greetingText = computed(() => {
const h = dayjs().hour();
if (h < 12) return '上午好';
if (h < 18) return '下午好';
return '晚上好';
});
const welcomeTip = computed(() => {
const n = summary.taskWaitCount;
if (n > 0) return `您有 ${n} 条待办待处理,建议优先处理今日到达的审批任务。`;
return '今日暂无待办。';
});
const unreadMessageCount = computed(() => messageList.value.filter((m) => !m.read).length);
const summaryCards = computed(() => [
{
key: 'taskWait',
label: '待我审批',
displayValue: summary.taskWaitCount,
theme: 'primary',
icon: Tickets,
menuComponent: 'workflow/task/taskWaiting'
},
{
key: 'taskCopy',
label: '抄送我的',
displayValue: summary.taskCopyCount,
theme: 'purple',
icon: Message,
menuComponent: 'workflow/task/taskCopyList'
},
{
key: 'myProcess',
label: '发起进行中',
displayValue: summary.myProcessRunningCount,
theme: 'success',
icon: Document,
menuComponent: 'workflow/task/myDocument'
},
{
key: 'timesheet',
label: '本周工时',
displayValue: summary.timesheetWeekLabel,
theme: summary.timesheetWeekStatus === 'NOT_FILLED' ? 'warning' : 'info',
icon: Calendar,
menuComponent: 'oa/erp/timesheetInfo/index'
},
{
key: 'contract',
label: '合同信息',
displayValue: summary.contractCount,
theme: 'cyan',
icon: Notebook,
menuComponent: 'oa/erp/contractInfo/index'
}
]);
/** 自然周周一至周日 */
const getCurrentWeekRange = () => {
const d = dayjs();
const day = d.day();
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = d.add(mondayOffset, 'day');
return {
start: monday.format('YYYY-MM-DD'),
end: monday.add(6, 'day').format('YYYY-MM-DD')
};
};
const flowStatusLabel = (status: string) => {
return proxy?.selectDictLabel(wf_business_status.value, status) || status || '—';
};
const flowStatusTagType = (status: string): 'success' | 'warning' | 'info' | 'danger' | 'primary' => {
if (status === 'finish') return 'success';
if (status === 'waiting') return 'warning';
if (status === 'back' || status === 'cancel') return 'danger';
if (status === 'draft') return 'info';
return 'primary';
};
const openMenu = (menuComponent: string, query?: Record<string, unknown>) => {
const path = resolveMenuPath(menuComponent);
if (!path) {
proxy?.$modal.msgWarning('未找到对应菜单路由,请确认已分配菜单权限');
return;
}
proxy?.$tab.openPage(path, undefined, query);
};
const loadProfile = async () => {
try {
const res = await getUserProfile();
const user = res.data?.user;
if (user?.deptName) {
profileDept.value = user.deptName;
}
} catch {
/* 忽略 */
}
};
const loadTaskWait = async () => {
const res = await pageByTaskWait({ pageNum: 1, pageSize: 5 });
taskWaitList.value = res.rows || [];
summary.taskWaitCount = res.total ?? taskWaitList.value.length;
};
const loadTaskCopy = async () => {
const res = await pageByTaskCopy({ pageNum: 1, pageSize: 1 });
summary.taskCopyCount = res.total ?? 0;
};
const loadMyProcess = async () => {
processLoading.value = true;
try {
const res = await pageByCurrent({ pageNum: 1, pageSize: 20 });
const rows = (res.rows || []) as FlowInstanceVO[];
const running = rows.filter((r) => RUNNING_FLOW_STATUS.includes(r.flowStatus));
summary.myProcessRunningCount = running.length;
myProcessList.value = running.slice(0, 5).map((row) => {
const nodeName = row.flowTaskList?.[0]?.nodeName || row.flowStatusName || '—';
return {
id: row.id,
businessId: row.businessId,
businessTitle: row.businessTitle || '—',
flowName: row.flowName || '—',
nodeName,
flowStatus: row.flowStatus,
flowStatusLabel: flowStatusLabel(row.flowStatus),
statusType: flowStatusTagType(row.flowStatus),
createTime: row.createTime ? proxy.parseTime(row.createTime) : '—',
formCustom: row.flowTaskList?.[0]?.formCustom ?? '',
formPath: row.flowTaskList?.[0]?.formPath ?? ''
};
});
} finally {
processLoading.value = false;
}
};
const resolveTimesheetWeek = async () => {
const week = getCurrentWeekRange();
const res = await listTimesheetInfo({
pageNum: 1,
pageSize: 1,
userId: userStore.userId,
startTime: week.start,
endTime: week.end
});
const row = (res.rows?.[0] || null) as TimesheetInfoVO | null;
if (!row) {
summary.timesheetWeekStatus = 'NOT_FILLED';
summary.timesheetWeekLabel = '未填报';
return;
}
const status = row.timesheetStatus || '';
summary.timesheetWeekStatus = status === '1' ? 'DRAFT' : status === '3' ? 'APPROVED' : status === '2' ? 'SUBMITTED' : status;
summary.timesheetWeekLabel =
proxy?.selectDictLabel(timesheet_status.value, status) ||
(status === '1' ? '暂存' : status === '2' ? '审批中' : status === '3' ? '已审批' : '已填报');
};
const loadContractCount = async () => {
try {
const res = await listContractInfo({ pageNum: 1, pageSize: 1 });
summary.contractCount = res.total ?? 0;
} catch {
summary.contractCount = 0;
}
};
const loadNotices = async () => {
const res = await listNotice({
pageNum: 1,
pageSize: 5,
noticeTitle: '',
createByName: '',
status: '0',
noticeType: ''
});
const rows = (res.rows || []) as NoticeVO[];
noticeList.value = rows.map((n) => ({
noticeId: n.noticeId,
noticeTitle: n.noticeTitle,
noticeTypeLabel: proxy?.selectDictLabel(sys_notice_type.value, n.noticeType) || '公告',
tagType: (n.noticeType === '1' ? 'success' : 'info') as 'success' | 'info',
createTime: n.createTime ? proxy.parseTime(n.createTime, '{y}-{m}-{d}') : ''
}));
};
const loadDashboard = async () => {
pageLoading.value = true;
try {
await Promise.allSettled([
loadProfile(),
loadTaskWait(),
loadTaskCopy(),
loadMyProcess(),
resolveTimesheetWeek(),
loadContractCount(),
loadNotices()
]);
} finally {
pageLoading.value = false;
}
};
const handleRefresh = () => {
loadDashboard();
if (noticeTab.value === 'calendar') {
workCalendarRef.value?.reload();
}
};
const focusMessages = () => {
noticeTab.value = 'message';
};
const onMessageClick = (index: number) => {
const notices = noticeStore.state.notices;
if (notices[index] && !notices[index].read) {
notices[index].read = true;
}
};
const onSummaryClick = (card: { menuComponent?: string }) => {
if (card.menuComponent) {
openMenu(card.menuComponent);
}
};
const taskCreateByName = (row: FlowTaskVO) => (row as FlowTaskVO & { createByName?: string }).createByName;
const onTaskClick = (row: FlowTaskVO) => {
const routerJumpVo = reactive<RouterJumpVo>({
businessId: row.businessId,
taskId: row.id,
type: 'approval',
formCustom: row.formCustom,
formPath: row.formPath
});
workflowCommon.routerJump(routerJumpVo, proxy);
};
const onProcessRowClick = (row: (typeof myProcessList.value)[0]) => {
const routerJumpVo = reactive<RouterJumpVo>({
businessId: row.businessId,
taskId: row.id,
type: 'view',
formCustom: row.formCustom,
formPath: row.formPath
});
workflowCommon.routerJump(routerJumpVo, proxy);
};
const onNoticeClick = (_n?: { noticeTitle?: string }) => {
openMenu('system/notice/index');
};
const onShortcutClick = (s: { menuComponent: string }) => {
openMenu(s.menuComponent);
};
const onCalendarTaskClick = (task: CalendarPendingTask) => {
const routerJumpVo = reactive<RouterJumpVo>({
businessId: task.businessId,
taskId: task.id,
type: 'approval',
formCustom: task.formCustom,
formPath: task.formPath
});
workflowCommon.routerJump(routerJumpVo, proxy);
};
onMounted(() => {
loadDashboard();
});
</script>
<style lang="scss" scoped>
.dashboard {
padding-bottom: 16px;
background: var(--el-bg-color-page, #f0f2f5);
min-height: calc(100vh - 84px);
}
/* A. 欢迎区 */
.welcome-banner {
position: relative;
overflow: hidden;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
margin-bottom: 16px;
border-radius: 8px;
background: linear-gradient(to right, #0047ab 0%, #1e90ff 100%);
color: #fff;
box-shadow:
0 4px 20px rgba(0, 71, 171, 0.22),
0 1px 4px rgba(30, 144, 255, 0.12);
&__main,
&__actions {
position: relative;
z-index: 1;
}
&__greet {
font-size: 22px;
font-weight: 600;
line-height: 1.4;
}
&__name {
margin-left: 4px;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 8px 0 0;
font-size: 13px;
opacity: 0.92;
.meta-icon {
margin-right: 4px;
}
}
&__date {
display: inline-flex;
align-items: center;
}
&__tip {
margin: 10px 0 0;
font-size: 13px;
opacity: 0.88;
max-width: 560px;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
:deep(.el-button) {
--el-button-bg-color: rgba(255, 255, 255, 0.15);
--el-button-border-color: rgba(255, 255, 255, 0.5);
--el-button-hover-bg-color: rgba(255, 255, 255, 0.28);
--el-button-text-color: #fff;
}
}
}
/* B. 概览卡片 */
.summary-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 16px;
@media (max-width: 1200px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
.summary-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--el-bg-color);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
}
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
&__value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
color: var(--el-text-color-primary);
}
&__label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
&--primary .summary-card__icon {
background: linear-gradient(135deg, #409eff, #66b1ff);
}
&--purple .summary-card__icon {
background: linear-gradient(135deg, #9b59b6, #b07cc6);
}
&--success .summary-card__icon {
background: linear-gradient(135deg, #67c23a, #85ce61);
}
&--warning .summary-card__icon {
background: linear-gradient(135deg, #e6a23c, #ebb563);
}
&--info .summary-card__icon {
background: linear-gradient(135deg, #909399, #b1b3b8);
}
&--cyan .summary-card__icon {
background: linear-gradient(135deg, #00bcd4, #26c6da);
}
}
.dashboard-main {
align-items: flex-start;
}
.block-card {
margin-bottom: 16px;
&--last {
margin-bottom: 0;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 15px;
}
}
/* C. 待办列表 */
.task-list {
&__item {
padding: 12px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
cursor: pointer;
transition: background 0.15s;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&:first-child {
padding-top: 0;
}
&:hover {
background: var(--el-fill-color-light);
margin: 0 -12px;
padding-left: 12px;
padding-right: 12px;
border-radius: 6px;
}
}
&__title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 6px;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
&__node::before {
content: '·';
margin-right: 4px;
color: var(--el-text-color-placeholder);
}
&__time {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 6px;
}
}
.compact-table {
:deep(.el-table__row) {
cursor: pointer;
}
}
/* D. 公告 */
.notice-tabs {
:deep(.el-tabs__header) {
margin-bottom: 8px;
}
:deep(.el-tabs__item) {
font-size: 13px;
padding: 0 14px;
}
:deep(.el-tab-pane) {
min-height: 0;
}
}
.notice-tabs--calendar {
:deep(.el-tabs__content) {
overflow: visible;
}
:deep(.el-card__body) {
padding-bottom: 12px;
}
}
.tab-badge {
margin-left: 6px;
vertical-align: middle;
}
.notice-feed {
list-style: none;
margin: 0;
padding: 0;
&__item {
position: relative;
padding: 10px 0 10px 0;
border-bottom: 1px solid var(--el-border-color-extra-light);
cursor: pointer;
&:last-child {
border-bottom: none;
}
&:hover .notice-feed__title {
color: var(--el-color-primary);
}
&.is-unread {
padding-left: 12px;
}
}
&__dot {
position: absolute;
left: 0;
top: 14px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-color-danger);
}
&__title {
display: block;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-primary);
margin-top: 4px;
transition: color 0.15s;
}
&__time {
display: block;
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 4px;
}
}
/* F. 快捷入口 */
.shortcut-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px 12px;
@media (max-width: 1200px) {
grid-template-columns: repeat(3, 1fr);
}
&__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
transition: transform 0.15s;
&:hover {
transform: scale(1.05);
.shortcut-grid__label {
color: var(--el-color-primary);
}
}
}
&__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);
}
&__label {
font-size: 12px;
color: var(--el-text-color-regular);
text-align: center;
line-height: 1.3;
}
}
</style>