Files
yunzerwebsiteallinone/backend/src/views/apps/oa/dashboard/index.vue
T
2026-09-09 23:51:00 +08:00

1426 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="oa-dashboard">
<div class="page-header">
<div>
<h2>OA 工作台</h2>
<p>个人办公数据概览</p>
</div>
<el-button :icon="Refresh" @click="loadDashboard">刷新</el-button>
</div>
<div class="dashboard-columns">
<!-- 左侧 60%:报销相关内容 -->
<div class="col-left">
<!-- 待办概览卡片:今日 / 本周 / 本月 待办数量 -->
<div class="todo-stats-row">
<div class="todo-stat-card">
<div class="todo-stat-label">今日待办</div>
<div class="todo-stat-value">{{ todoTodayPending }}</div>
<div class="todo-stat-sub">
共 {{ scheduleStats.today_total || 0 }} 项 · 已完成
{{ scheduleStats.today_done || 0 }}
</div>
</div>
<div class="todo-stat-card">
<div class="todo-stat-label">本周待办</div>
<div class="todo-stat-value">{{ todoWeekPending }}</div>
<div class="todo-stat-sub">
共 {{ scheduleStats.week_total || 0 }} 项 · 已完成
{{ scheduleStats.week_done || 0 }}
</div>
</div>
<div class="todo-stat-card">
<div class="todo-stat-label">本月待办</div>
<div class="todo-stat-value">{{ todoMonthPending }}</div>
<div class="todo-stat-sub">
共 {{ scheduleStats.month_total || 0 }} 项 · 已完成
{{ scheduleStats.month_done || 0 }}
</div>
</div>
</div>
<!-- 报销统计卡片 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="8" :xs="24" v-for="item in statCards" :key="item.key">
<div class="stat-card">
<div class="stat-title">{{ item.label }}</div>
<div class="stat-amount">
<span class="currency">¥</span>
<span class="number">{{ money(item.data.paid) }}</span>
</div>
<div class="stat-meta">
<span>{{ item.data.count }} 单</span>
<span>未报销 ¥ {{ money(item.data.unpaid) }}</span>
<span>报销总额 ¥ {{ money(item.data.total) }}</span>
</div>
</div>
</el-col>
</el-row>
<!-- 报销趋势 -->
<div class="chart-card">
<div class="chart-title">近6个月报销趋势</div>
<div ref="trendChartRef" class="chart-container"></div>
</div>
<!-- 状态分布 -->
<div class="chart-card status-card">
<div class="chart-title">状态分布</div>
<div class="status-dist">
<div
v-for="(item, key) in dashboard.status_dist"
:key="key"
class="dist-row"
>
<el-tag :type="statusType(Number(key))" size="small">{{
item.name
}}</el-tag>
<div class="dist-bar-wrap">
<div
class="dist-bar"
:style="{ width: distPercent(item.count) }"
/>
</div>
<span class="dist-count"
>{{ item.count }} 单 / ¥ {{ money(item.amount) }}</span
>
</div>
<el-empty
v-if="!Object.keys(dashboard.status_dist).length"
description="暂无数据"
:image-size="60"
/>
</div>
</div>
<!-- 报销类型分析 -->
<div class="chart-card type-card">
<div class="chart-title">报销类型分析</div>
<div class="type-dist">
<div
v-for="item in dashboard.expense_types"
:key="item.name"
class="type-row"
>
<span class="type-name">{{ item.name }}</span>
<div class="type-bar-wrap">
<div
class="type-bar"
:style="{ width: typePercent(item.amount) }"
/>
</div>
<span class="type-count">{{ item.count }} 笔</span>
<span class="type-amount">¥ {{ money(item.amount) }}</span>
</div>
<el-empty
v-if="!dashboard.expense_types.length"
description="暂无费用明细"
:image-size="60"
/>
</div>
</div>
</div>
<!-- 右侧 30%:通知公告 + 工作日程提醒 -->
<div class="col-right">
<div class="chart-card notice-card">
<div class="chart-title notice-title">
<span>通知公告</span>
<el-button link type="primary" @click="goNotice"
>查看全部</el-button
>
</div>
<div v-loading="noticeLoading" class="notice-list">
<div
v-for="item in notices"
:key="item.id"
class="notice-item"
@click="openNoticeDetail(item)"
>
<el-tag
:type="noticeTypeTag(item.notice_type)"
size="small"
effect="light"
class="notice-type"
>
{{ noticeTypeLabel(item.notice_type) }}
</el-tag>
<span v-if="item.is_top === 1" class="notice-top">置顶</span>
<span class="notice-text">{{ item.title }}</span>
<span class="notice-time">{{ noticeTime(item) }}</span>
</div>
<el-empty
v-if="!notices.length && !noticeLoading"
description="暂无通知公告"
:image-size="60"
/>
</div>
</div>
<div class="chart-card schedule-card">
<div class="chart-title schedule-title">
<span>工作日程提醒</span>
<el-button link type="primary" @click="goSchedule"
>查看全部</el-button
>
</div>
<el-tabs v-model="scheduleRange" class="schedule-tabs">
<el-tab-pane
v-for="tab in scheduleRanges"
:key="tab.value"
:name="tab.value"
>
<template #label>
<span class="tab-label">{{ tab.label }}</span>
</template>
</el-tab-pane>
</el-tabs>
<div v-loading="scheduleLoading" class="schedule-list">
<div
v-for="item in filteredScheduleReminders"
:key="item.id"
class="schedule-item"
:class="{ done: item.status === 1 }"
:style="{ '--item-color': item.color }"
@click="openScheduleDetail(item)"
>
<span
class="item-status-dot"
:class="{ done: item.status === 1 }"
/>
<div class="schedule-item-main">
<div
class="schedule-item-title"
:class="{ done: item.status === 1 }"
>
<i v-if="hasImages(item)" class="fa-solid fa-image schedule-image-icon"></i>
{{ item.title }}
<el-tag
v-if="item.priority > 0"
:type="item.priority === 2 ? 'danger' : 'warning'"
size="small"
effect="light"
>
{{ item.priority === 2 ? "紧急" : "重要" }}
</el-tag>
<el-tag
v-if="item.source_id > 0 && item.status === 0"
type="warning"
size="small"
effect="plain"
>
延期第 {{ item.carry_count + 1 }} 天
</el-tag>
</div>
<div
class="schedule-item-time"
:class="{ done: item.status === 1 }"
>
<span :class="{ overdue: isOverdueSchedule(item) }">{{
scheduleDateTag(item)
}}</span>
<span>{{ scheduleTimeText(item) }}</span>
</div>
</div>
</div>
<el-empty
v-if="!filteredScheduleReminders.length && !scheduleLoading"
:description="
scheduleRange === 'today'
? '今日暂无日程安排'
: '该时间范围内暂无日程'
"
:image-size="60"
/>
</div>
</div>
</div>
</div>
<!-- 通知公告详情 -->
<el-dialog
v-model="noticeDetailVisible"
:title="noticeDetailItem?.title"
width="620px"
>
<div v-if="noticeDetailItem" class="notice-detail-body">
<div class="notice-detail-meta">
<el-tag
:type="noticeTypeTag(noticeDetailItem.notice_type)"
size="small"
effect="light"
>
{{ noticeTypeLabel(noticeDetailItem.notice_type) }}
</el-tag>
<span class="notice-detail-text"
>{{ noticeDetailItem.publisher_name }} ·
{{ formatDateTime(noticeDetailItem.publish_time) }}</span
>
<span class="notice-detail-text"
>阅读 {{ noticeDetailItem.read_count || 0 }}</span
>
</div>
<div class="notice-detail-content">
{{ noticeDetailItem.content || "暂无内容" }}
</div>
</div>
</el-dialog>
<!-- 日程详情抽屉:点击任务后由用户明确选择延期 / 已完成 / 反审核 -->
<ScheduleDetail
v-model="scheduleDetailVisible"
:schedule="scheduleDetailItem"
:editable="false"
@finish="handleScheduleFinish"
@revert="handleScheduleRevert"
@postpone="handleSchedulePostpone"
/>
<!-- 延期对话框 -->
<SchedulePostpone
v-model="schedulePostponeVisible"
:schedule="scheduleDetailItem"
@saved="onPostponeSaved"
/>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import * as echarts from "echarts";
import { Refresh } from "@element-plus/icons-vue";
import { getReimbursementDashboard } from "@/api/reimburse";
import {
getNoticeDetail,
getNoticePortal
} from "@/api/oaNotice";
import { finishSchedule, getScheduleList, getScheduleStats } from "@/api/oaSchedule";
import ScheduleDetail from "../schedule/components/detail.vue";
import SchedulePostpone from "../schedule/components/postponeDialog.vue";
const emptyStat = () => ({ total: 0, paid: 0, unpaid: 0, count: 0 });
const dashboard = reactive({
week: emptyStat(),
month: emptyStat(),
year: emptyStat(),
trend: [],
status_dist: {},
expense_types: [],
});
const statCards = computed(() => [
{ key: "week", label: "本周报销", data: dashboard.week },
{ key: "month", label: "本月报销", data: dashboard.month },
{ key: "year", label: "本年报销", data: dashboard.year },
]);
// 趋势图
const trendChartRef = ref(null);
let trendChart = null;
let themeObserver = null;
const renderTrendChart = () => {
if (!trendChartRef.value) return;
if (!trendChart) trendChart = echarts.init(trendChartRef.value);
const dark = document.documentElement.classList.contains("dark");
const axisLineColor = dark ? "#3a3a3a" : "#E0E6ED";
const labelColor = dark ? "#A3A6AD" : "#606266";
const splitColor = dark ? "#2a2a2a" : "#F2F6FC";
const months = dashboard.trend.map((item) => item.month.slice(5) + "月");
trendChart.setOption({
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
legend: { data: ["已报销", "未报销"], top: 0, textStyle: { color: labelColor } },
grid: {
left: "3%",
right: "4%",
bottom: "3%",
top: "18%",
containLabel: true,
},
xAxis: {
type: "category",
data: months,
axisLine: { lineStyle: { color: axisLineColor } },
axisLabel: { color: labelColor, fontSize: 11 },
},
yAxis: {
type: "value",
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: splitColor } },
axisLabel: { color: labelColor, fontSize: 11 },
},
series: [
{
name: "已报销",
type: "bar",
stack: "total",
barMaxWidth: 32,
itemStyle: { color: "#67C23A" },
data: dashboard.trend.map((item) => Number(item.paid || 0)),
},
{
name: "未报销",
type: "bar",
stack: "total",
barMaxWidth: 32,
itemStyle: { color: "#E6A23C" },
data: dashboard.trend.map((item) => Number(item.unpaid || 0)),
},
],
});
};
// 状态分布占比
const distTotal = computed(() =>
Object.values(dashboard.status_dist).reduce(
(sum, item) => sum + Number(item.count || 0),
0,
),
);
const distPercent = (count) =>
distTotal.value
? `${Math.round((Number(count || 0) / distTotal.value) * 100)}%`
: "0%";
// 类型分析占比
const maxTypeAmount = computed(() =>
Math.max(1, ...dashboard.expense_types.map((item) => Number(item.amount || 0))),
);
const typePercent = (amount) =>
`${Math.round((Number(amount || 0) / maxTypeAmount.value) * 100)}%`;
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
// ---------- 待办概览(顶部卡片) ----------
const scheduleStats = ref({});
const todoTodayPending = computed(
() => (scheduleStats.value.today_total || 0) - (scheduleStats.value.today_done || 0),
);
const todoWeekPending = computed(
() => (scheduleStats.value.week_total || 0) - (scheduleStats.value.week_done || 0),
);
const todoMonthPending = computed(
() => (scheduleStats.value.month_total || 0) - (scheduleStats.value.month_done || 0),
);
// ---------- 工作日程提醒 ----------
const router = useRouter();
const scheduleReminders = ref([]);
const scheduleLoading = ref(false);
// 时间范围筛选:今日(默认)/ 本周 / 本月 / 本季度 / 本年
const scheduleRanges = [
{ value: "today", label: "今日" },
{ value: "week", label: "本周" },
{ value: "month", label: "本月" },
{ value: "quarter", label: "本季度" },
{ value: "year", label: "本年" },
];
const scheduleRange = ref("today");
const pad2 = (n) => String(n).padStart(2, "0");
const fmtDate = (d) =>
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
// 计算指定时间范围的起止日期(含本周一 ~ 周日、季度首末月等)
const rangeDates = (range) => {
const now = new Date();
const today = fmtDate(now);
if (range === "today") {
return { start: today, end: today };
}
if (range === "week") {
// 周一为一周起点
const weekday = now.getDay() || 7;
const start = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - (weekday - 1),
);
const end = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() + (7 - weekday),
);
return { start: fmtDate(start), end: fmtDate(end) };
}
if (range === "month") {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return { start: fmtDate(start), end: fmtDate(end) };
}
if (range === "quarter") {
const q = Math.floor(now.getMonth() / 3);
const start = new Date(now.getFullYear(), q * 3, 1);
const end = new Date(now.getFullYear(), q * 3 + 3, 0);
return { start: fmtDate(start), end: fmtDate(end) };
}
// year
const start = new Date(now.getFullYear(), 0, 1);
const end = new Date(now.getFullYear(), 11, 31);
return { start: fmtDate(start), end: fmtDate(end) };
};
// 列表直接展示按所选时间范围拉取的数据(已完成灰显沉底)
const filteredScheduleReminders = computed(() => scheduleReminders.value);
// 切换时间范围时重新拉取对应区间的日程
watch(scheduleRange, () => loadScheduleReminders());
// 按所选时间范围拉取日程(含已完成,完成后灰显沉底而非从列表消失)
const loadScheduleReminders = async (range) => {
const r = range || scheduleRange.value;
scheduleLoading.value = true;
try {
const { start, end } = rangeDates(r);
const res = await getScheduleList({
start_date: start,
end_date: end,
page: 1,
pageSize: 500,
});
scheduleReminders.value = (res?.data?.list || [])
.map((item) => ({
...item,
// 统一日期格式,避免后端 parseTime 输出 Go 默认格式导致标签解析 NaN
schedule_date: String(item.schedule_date || "").slice(0, 10),
}))
.sort((a, b) => {
// 未完成在前,已完成沉底,保证点击完成后条目保留可见(灰显)
if (a.status !== b.status) return a.status - b.status;
return `${a.schedule_date}${a.start_time}`.localeCompare(
`${b.schedule_date}${b.start_time}`,
);
});
} catch (error) {
console.warn("加载日程提醒失败:", error?.message);
} finally {
scheduleLoading.value = false;
}
};
// 列表项日期标签:已逾期N天 / 今天 / 明天 / M/D 周X
const scheduleDateTag = (item) => {
const today = new Date();
const todayStr = fmtDate(today);
if (item.schedule_date === todayStr) return "今天";
if (item.schedule_date < todayStr) {
const diff = Math.round(
(new Date(todayStr) - new Date(item.schedule_date)) / 86400000,
);
return `已逾期${diff}天`;
}
const tomorrow = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 1,
);
if (fmtDate(tomorrow) === item.schedule_date) return "明天";
const [y, m, d] = item.schedule_date.split("-").map(Number);
const week = "日一二三四五六"[new Date(y, m - 1, d).getDay()];
return `${m}/${d} 周${week}`;
};
// 判断日程是否关联了图片(与详情页 parseImages 语义一致),用于列表标题前展示图片图标
function hasImages(item) {
const imgs = item?.images;
if (!imgs) return false;
if (Array.isArray(imgs)) return imgs.some(img => img && img.url);
if (typeof imgs === "string" && imgs.trim()) {
try {
const parsed = JSON.parse(imgs);
return Array.isArray(parsed) && parsed.some(img => img && img.url);
} catch {
return false;
}
}
return false;
}
const scheduleTimeText = (item) =>
item.all_day === 1
? "全天"
: item.end_time
? `${item.start_time} - ${item.end_time}`
: item.start_time;
const isOverdueSchedule = (item) =>
item.status === 0 && item.schedule_date < fmtDate(new Date());
// 点击任务只打开详情抽屉,由用户明确选择延期 / 已完成 / 反审核,避免误点即完成
const scheduleDetailVisible = ref(false);
const scheduleDetailItem = ref(null);
const schedulePostponeVisible = ref(false);
const openScheduleDetail = (item) => {
scheduleDetailItem.value = item;
scheduleDetailVisible.value = true;
};
const toggleScheduleFinish = async (item) => {
const res = await finishSchedule(item.id);
if (res?.code === 200) {
ElMessage.success(res.data?.status === 1 ? "已完成" : "已反审核");
scheduleDetailVisible.value = false;
loadScheduleReminders();
// 完成/反审核会改变待办数量,刷新顶部概览卡片
loadScheduleStats();
} else {
ElMessage.error(res?.msg || "操作失败");
}
};
const handleScheduleFinish = (item) => toggleScheduleFinish(item);
const handleScheduleRevert = (item) => toggleScheduleFinish(item);
const handleSchedulePostpone = (item) => {
scheduleDetailItem.value = item;
scheduleDetailVisible.value = false;
schedulePostponeVisible.value = true;
};
// 顺延保存后:原日程标记完成并生成次日待办,需同步刷新列表与顶部概览卡片
const onPostponeSaved = () => {
loadScheduleReminders();
loadScheduleStats();
};
const goSchedule = () => router.push("/apps/oa/schedule");
// 跳转到日程管理页并打开该日程的编辑抽屉
const editSchedule = (item) => {
// 通过 sessionStorage 把待编辑日程传递给日程管理页,由其打开编辑抽屉
sessionStorage.setItem("schedule_edit_item", JSON.stringify(item));
scheduleActionVisible.value = false;
router.push("/apps/oa/schedule");
};
// ---------- 通知公告 ----------
const notices = ref([]);
const noticeLoading = ref(false);
const noticeDetailVisible = ref(false);
const noticeDetailItem = ref(null);
const noticeTypeLabel = (type) => ({ 1: "公告", 2: "活动" }[type] || "通知");
const noticeTypeTag = (type) => ({ 1: "primary", 2: "success" }[type] || "info");
const formatDateTime = (value) => {
if (!value) return "-";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(
d.getHours()
)}:${pad2(d.getMinutes())}`;
};
// 列表右侧时间:今天显示 HH:mm,本年显示 M/D,跨年显示 YYYY-M-D
const noticeTime = (item) => {
const d = item.publish_time ? new Date(item.publish_time) : null;
if (!d || Number.isNaN(d.getTime())) return "";
const now = new Date();
if (fmtDate(d) === fmtDate(now)) {
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
if (d.getFullYear() === now.getFullYear()) {
return `${d.getMonth() + 1}/${d.getDate()}`;
}
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
};
const loadNotices = async () => {
noticeLoading.value = true;
try {
const res = await getNoticePortal({ limit: 6 });
notices.value = res?.data?.list || [];
} catch (error) {
console.warn("加载通知公告失败:", error?.message);
} finally {
noticeLoading.value = false;
}
};
const openNoticeDetail = async (item) => {
const res = await getNoticeDetail(item.id);
if (res?.code === 200) {
noticeDetailItem.value = res.data;
noticeDetailVisible.value = true;
} else {
ElMessage.error(res?.msg || "加载公告失败");
}
};
const goNotice = () => router.push("/apps/oa/notice");
// 刷新顶部"待办概览"卡片:今日 / 本周 / 本月 未完成数量
const loadScheduleStats = async () => {
try {
const res = await getScheduleStats();
scheduleStats.value = res?.data || {};
} catch (error) {
console.warn("加载待办统计失败:", error?.message);
}
};
const loadDashboard = async () => {
try {
const data = responseData(await getReimbursementDashboard());
dashboard.week = data.week || emptyStat();
dashboard.month = data.month || emptyStat();
dashboard.year = data.year || emptyStat();
dashboard.trend = data.trend || [];
dashboard.status_dist = data.status_dist || {};
dashboard.expense_types = data.expense_types || [];
} catch (error) {
console.warn("加载报销统计失败:", error?.message);
}
await loadScheduleStats();
renderTrendChart();
loadScheduleReminders();
loadNotices();
};
const handleResize = () => trendChart?.resize();
const money = (value) => Number(value || 0).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const statusType = (value) =>
({ 0: "info", 1: "warning", 2: "success", 3: "danger", 4: "info", 5: "success" })[value] ||
"info";
onMounted(async () => {
await loadDashboard();
window.addEventListener("resize", handleResize);
// 主题切换时重绘图表,使坐标轴/文字颜色跟随暗色
themeObserver = new MutationObserver(() => renderTrendChart());
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize);
themeObserver?.disconnect();
themeObserver = null;
trendChart?.dispose();
trendChart = null;
});
</script>
<style lang="less" scoped>
.oa-dashboard {
background: #f5f7fa;
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0 0 8px;
color: #303133;
font-size: 22px;
}
p {
margin: 0;
color: #909399;
font-size: 13px;
}
}
.stat-row {
margin-bottom: 16px;
.el-col {
margin-bottom: 8px;
}
}
.stat-card {
background: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
.stat-title {
color: #909399;
font-size: 13px;
margin-bottom: 6px;
}
.stat-amount {
display: flex;
align-items: baseline;
margin-bottom: 8px;
.currency {
font-size: 14px;
color: #303133;
font-weight: 500;
margin-right: 4px;
}
.number {
font-size: 24px;
font-weight: 600;
color: #303133;
}
}
.stat-meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
color: #909399;
font-size: 12px;
}
}
/* 待办概览卡片(顶部) */
.todo-stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.todo-stat-card {
background: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.todo-stat-label {
font-size: 13px;
color: #909399;
margin-bottom: 6px;
}
.todo-stat-value {
font-size: 28px;
font-weight: 600;
line-height: 1.2;
color: #303133;
}
.todo-stat-sub {
margin-top: 6px;
font-size: 12px;
color: #c0c4cc;
}
/* 左右分栏:左 70% 报销,右 30% 日程提醒 */
.dashboard-columns {
display: flex;
align-items: flex-start;
gap: 16px;
}
.col-left {
flex: 0 0 calc(70% - 8px);
min-width: 0;
}
.col-right {
flex: 0 0 calc(30% - 8px);
min-width: 0;
}
.col-left > .chart-card {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.chart-card {
background: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
.chart-title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
.chart-container {
height: 280px;
width: 100%;
}
}
.trend-card {
height: 398px;
display: flex;
flex-direction: column;
.chart-container {
flex: 1;
min-height: 0;
height: auto;
}
}
.type-card {
margin-bottom: 0;
}
.notice-card {
margin-bottom: 16px;
.notice-title {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.notice-list {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 80px;
max-height: 240px;
overflow-y: auto;
}
.notice-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
background: #fafbfc;
&:hover {
background: #eef2f7;
}
.notice-type {
flex-shrink: 0;
}
.notice-top {
flex-shrink: 0;
font-size: 11px;
color: #f56c6c;
}
.notice-text {
flex: 1;
min-width: 0;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-time {
flex-shrink: 0;
color: #c0c4cc;
font-size: 12px;
}
}
.notice-detail-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
.notice-detail-text {
font-size: 12px;
color: #909399;
}
.notice-detail-content {
margin-top: 12px;
font-size: 14px;
line-height: 1.8;
color: #303133;
white-space: pre-wrap;
word-break: break-word;
max-height: 420px;
overflow-y: auto;
}
.schedule-card {
display: flex;
flex-direction: column;
/*min-height: 340px; */
height: 600px;
.schedule-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.schedule-tabs {
margin-bottom: 20px;
:deep(.el-tabs__header) {
margin: 0;
}
:deep(.el-tabs__nav-wrap::after) {
height: 1px;
}
:deep(.el-tabs__item) {
height: 36px;
line-height: 36px;
}
}
.schedule-list {
flex: 1;
}
}
.schedule-tabs {
:deep(.el-tabs__header) {
margin: 0 0 10px;
}
/* 列表由下方 .schedule-list 承载,tabs 只作切换栏,隐藏空的面板容器 */
:deep(.el-tabs__content) {
display: none;
}
:deep(.el-tabs__item) {
padding: 0 10px;
font-size: 13px;
}
}
.tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tab-count {
min-width: 18px;
padding: 0 5px;
height: 16px;
line-height: 16px;
border-radius: 8px;
background: #f0f2f5;
color: #909399;
font-size: 11px;
text-align: center;
}
.status-card {
margin-bottom: 16px;
}
.schedule-list {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 120px;
max-height: 520px;
overflow-y: auto;
}
.schedule-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 12px;
background: #fafbfc;
border-left: 3px solid var(--item-color);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #eef2f7;
}
&.done {
background: #f5f7fa;
border-left-color: #dcdfe6;
}
/* 状态指示点:仅标识状态,点击整行弹出详情由用户选择操作 */
.item-status-dot {
width: 10px;
height: 10px;
margin-top: 5px;
flex-shrink: 0;
border-radius: 50%;
background: var(--item-color);
&.done {
background: #c0c4cc;
}
}
.schedule-item-main {
flex: 1;
min-width: 0;
cursor: pointer;
}
.schedule-item-title {
font-size: 14px;
color: #303133;
word-break: break-all;
&.done {
text-decoration: line-through;
color: #c0c4cc;
}
}
.schedule-image-icon {
flex-shrink: 0;
margin-right: 4px;
color: var(--el-text-color-secondary);
}
.schedule-item-time {
display: flex;
gap: 10px;
margin-top: 2px;
font-size: 12px;
color: #909399;
&.done {
color: #c0c4cc;
}
}
.overdue {
color: #f56c6c;
font-weight: 500;
}
}
.schedule-action-content {
.schedule-action-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.schedule-action-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 13px;
color: #606266;
margin-bottom: 16px;
.overdue {
color: #f56c6c;
font-weight: 500;
}
}
.schedule-action-tip {
font-size: 13px;
color: #909399;
margin-bottom: 12px;
}
.schedule-action-btns {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
}
.status-dist,
.type-dist {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 6px;
}
.dist-row,
.type-row {
display: flex;
align-items: center;
gap: 10px;
}
.dist-bar-wrap,
.type-bar-wrap {
flex: 1;
height: 10px;
background: #f0f2f5;
border-radius: 5px;
overflow: hidden;
}
.dist-bar {
height: 100%;
background: #409eff;
border-radius: 5px;
transition: width 0.3s;
}
.type-bar {
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a);
border-radius: 5px;
transition: width 0.3s;
}
.dist-count {
color: #909399;
font-size: 12px;
white-space: nowrap;
}
.type-name {
width: 110px;
color: #606266;
font-size: 13px;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.type-count {
width: 50px;
color: #909399;
font-size: 12px;
text-align: right;
}
.type-amount {
width: 110px;
color: #303133;
font-size: 13px;
text-align: right;
white-space: nowrap;
}
/* 窄屏下左右分栏改为上下堆叠 */
@media (max-width: 1100px) {
.dashboard-columns {
flex-direction: column;
}
.col-left,
.col-right {
flex: 1 1 auto;
width: 100%;
}
.col-right {
margin-top: 16px;
}
}
@media (max-width: 768px) {
.oa-dashboard {
padding: 12px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.type-name {
width: 80px;
}
.type-amount {
width: auto;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.oa-dashboard {
background: var(--el-bg-color-page);
}
.page-header {
h2 {
color: var(--el-text-color-primary);
}
p {
color: var(--el-text-color-secondary);
}
}
.stat-card {
background: var(--el-bg-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
.stat-title {
color: var(--el-text-color-secondary);
}
.stat-amount {
.currency,
.number {
color: #fff;
}
}
.stat-meta {
color: var(--el-text-color-secondary);
}
}
.todo-stat-card {
background: var(--el-bg-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.todo-stat-label {
color: var(--el-text-color-secondary);
}
// 待办数字 0/5/10 在 base 写死了 #303133,暗色下漏写导致看不清
.todo-stat-value {
color: #fff;
}
.todo-stat-sub {
color: var(--el-text-color-placeholder);
}
.chart-card {
background: var(--el-bg-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
.chart-title {
color: var(--el-text-color-primary);
border-bottom-color: var(--el-border-color-lighter);
}
}
.notice-item {
background: var(--el-fill-color-light);
&:hover {
background: var(--el-fill-color);
}
.notice-text {
color: var(--el-text-color-primary);
}
.notice-time {
color: var(--el-text-color-placeholder);
}
}
.notice-detail-meta {
border-bottom-color: var(--el-border-color-lighter);
}
.notice-detail-text {
color: var(--el-text-color-secondary);
}
.notice-detail-content {
color: var(--el-text-color-primary);
}
.tab-count {
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.schedule-item {
background: var(--el-fill-color-light);
&:hover {
background: var(--el-fill-color);
}
&.done {
background: var(--el-bg-color-page);
border-left-color: var(--el-border-color);
}
.item-status-dot.done {
background: var(--el-text-color-placeholder);
}
.schedule-item-title {
color: var(--el-text-color-primary);
&.done {
color: var(--el-text-color-placeholder);
}
}
.schedule-item-time {
color: var(--el-text-color-secondary);
&.done {
color: var(--el-text-color-placeholder);
}
}
}
.schedule-action-title {
color: var(--el-text-color-primary);
}
.schedule-action-meta {
color: var(--el-text-color-regular);
}
.schedule-action-tip {
color: var(--el-text-color-secondary);
}
.dist-bar-wrap,
.type-bar-wrap {
background: var(--el-fill-color);
}
.dist-count {
color: var(--el-text-color-secondary);
}
.type-name {
color: var(--el-text-color-regular);
}
.type-count {
color: var(--el-text-color-secondary);
}
.type-amount {
color: var(--el-text-color-primary);
}
}
</style>