1426 lines
35 KiB
Vue
1426 lines
35 KiB
Vue
<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>
|