优化日程提醒模块

This commit is contained in:
2026-09-08 22:18:36 +08:00
parent 3d53f03246
commit 7f3de60423
17 changed files with 1516 additions and 270 deletions
+315 -120
View File
@@ -8,33 +8,93 @@
<el-button :icon="Refresh" @click="loadDashboard">刷新</el-button>
</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.total) }}</span>
</div>
<div class="stat-meta">
<span>{{ item.data.count }} 单</span>
<span>已报销 ¥ {{ money(item.data.paid) }}</span>
<span>未报销 ¥ {{ money(item.data.unpaid) }}</span>
</div>
</div>
</el-col>
</el-row>
<div class="dashboard-columns">
<!-- 左侧 60%:报销相关内容 -->
<div class="col-left">
<!-- 报销统计卡片 -->
<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.total) }}</span>
</div>
<div class="stat-meta">
<span>{{ item.data.count }} 单</span>
<span>已报销 ¥ {{ money(item.data.paid) }}</span>
<span>未报销 ¥ {{ money(item.data.unpaid) }}</span>
</div>
</div>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="16" class="chart-row">
<el-col :span="14" :xs="24">
<!-- 报销趋势 -->
<div class="chart-card">
<div class="chart-title">近6个月报销趋势</div>
<div ref="trendChartRef" class="chart-container"></div>
</div>
</el-col>
<el-col :span="10" :xs="24">
<!-- 状态分布 -->
<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>
<!-- 右侧 40%:工作日程提醒 -->
<div class="col-right">
<div class="chart-card schedule-card">
<div class="chart-title schedule-title">
<span>工作日程提醒</span>
@@ -42,19 +102,36 @@
>查看全部</el-button
>
</div>
<el-tabs v-model="scheduleTab" class="schedule-tabs">
<el-tab-pane
v-for="tab in scheduleTabs"
:key="tab.value"
:name="tab.value"
>
<template #label>
<span class="tab-label">
{{ tab.label }}
<span class="tab-count">{{
scheduleCounts[tab.value] || 0
}}</span>
</span>
</template>
</el-tab-pane>
</el-tabs>
<div v-loading="scheduleLoading" class="schedule-list">
<div
v-for="item in scheduleReminders"
v-for="item in filteredScheduleReminders"
:key="item.id"
class="schedule-item"
:class="{ done: item.status === 1 }"
:style="{ '--item-color': item.color }"
@click="openScheduleDetail(item)"
>
<el-checkbox
:model-value="item.status === 1"
@change="toggleScheduleFinish(item)"
<span
class="item-status-dot"
:class="{ done: item.status === 1 }"
/>
<div class="schedule-item-main" @click="toggleScheduleFinish(item)">
<div class="schedule-item-main">
<div
class="schedule-item-title"
:class="{ done: item.status === 1 }"
@@ -68,6 +145,14 @@
>
{{ 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"
@@ -81,68 +166,35 @@
</div>
</div>
<el-empty
v-if="!scheduleReminders.length && !scheduleLoading"
description="近期暂无日程安排"
v-if="!filteredScheduleReminders.length && !scheduleLoading"
:description="
scheduleTab === 'all'
? '近期暂无日程安排'
: '该分类下暂无日程'
"
:image-size="60"
/>
</div>
</div>
</el-col>
</el-row>
<!-- 状态分布 -->
<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>
<!-- 日程详情抽屉:点击任务后由用户明确选择延期 / 已完成 / 反审核 -->
<ScheduleDetail
v-model="scheduleDetailVisible"
:schedule="scheduleDetailItem"
:editable="false"
@finish="handleScheduleFinish"
@revert="handleScheduleRevert"
@postpone="handleSchedulePostpone"
/>
<!-- 延期对话框 -->
<SchedulePostpone
v-model="schedulePostponeVisible"
:schedule="scheduleDetailItem"
@saved="loadScheduleReminders"
/>
</div>
</template>
@@ -154,6 +206,8 @@ import * as echarts from "echarts";
import { Refresh } from "@element-plus/icons-vue";
import { getReimbursementDashboard } from "@/api/reimburse";
import { finishSchedule, getScheduleList } 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({
@@ -249,6 +303,51 @@ const router = useRouter();
const scheduleReminders = ref([]);
const scheduleLoading = ref(false);
// 待办分类:all-全部 today-今日 pending-未完成 done-已完成
// 注:matchScheduleCategory 仍保留 ongoing 分支(进行中 = 跨天延期中的任务),
// 当前不启用;以后要恢复,在下面数组里加一行 { value: "ongoing", label: "进行中" } 即可。
const scheduleTabs = [
{ value: "all", label: "全部" },
{ value: "today", label: "今日" },
{ value: "pending", label: "未完成" },
{ value: "done", label: "已完成" },
];
const scheduleTab = ref("all");
// 判断一条日程是否属于某个分类(与后端 category 语义保持一致)
const matchScheduleCategory = (item, category) => {
switch (category) {
case "today":
return item.schedule_date === fmtDate(new Date());
case "pending":
return item.status === 0;
// 进行中 = 跨天顺延中的任务:被顺延出去的源日程,或顺延出来的续做日程
case "ongoing":
return item.source_id > 0 || item.carry_over === 1;
case "done":
return item.status === 1;
default:
return true;
}
};
const filteredScheduleReminders = computed(() =>
scheduleReminders.value.filter((item) =>
matchScheduleCategory(item, scheduleTab.value),
),
);
// 各分类数量按当前面板范围(逾期 + 未来7天)统计
const scheduleCounts = computed(() => {
const result = {};
for (const tab of scheduleTabs) {
result[tab.value] = scheduleReminders.value.filter((item) =>
matchScheduleCategory(item, tab.value),
).length;
}
return result;
});
const pad2 = (n) => String(n).padStart(2, "0");
const fmtDate = (d) =>
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
@@ -258,44 +357,29 @@ const loadScheduleReminders = async () => {
scheduleLoading.value = true;
try {
const today = new Date();
const todayStr = fmtDate(today);
const weekLater = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 7,
);
const yesterday = fmtDate(
new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1),
);
const [overdueRes, upcomingRes] = await Promise.all([
getScheduleList({
end_date: yesterday,
page: 1,
pageSize: 50,
}),
getScheduleList({
start_date: todayStr,
end_date: fmtDate(weekLater),
page: 1,
pageSize: 50,
}),
]);
const overdue = (overdueRes?.data?.list || []).map(item => ({
...item,
// 统一日期格式,避免后端 parseTime 输出 Go 默认格式导致标签解析 NaN
schedule_date: String(item.schedule_date || "").slice(0, 10),
}));
const upcoming = (upcomingRes?.data?.list || []).map(item => ({
...item,
schedule_date: String(item.schedule_date || "").slice(0, 10),
}));
scheduleReminders.value = [...overdue, ...upcoming].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}`,
);
const res = await getScheduleList({
end_date: fmtDate(weekLater),
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 {
@@ -335,16 +419,36 @@ const scheduleTimeText = (item) =>
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 ? "已完成" : "已恢复待办");
ElMessage.success(res.data?.status === 1 ? "已完成" : "已反审核");
scheduleDetailVisible.value = false;
loadScheduleReminders();
} 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 goSchedule = () => router.push("/apps/oa/schedule");
const loadDashboard = async () => {
@@ -455,11 +559,28 @@ onBeforeUnmount(() => {
}
}
.chart-row {
/* 左右分栏:左 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;
.el-col {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
@@ -489,9 +610,10 @@ onBeforeUnmount(() => {
}
.schedule-card {
height: 100%;
display: flex;
flex-direction: column;
/*min-height: 340px; */
height: 600px;
.schedule-title {
display: flex;
@@ -504,6 +626,40 @@ onBeforeUnmount(() => {
}
}
.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;
}
@@ -512,7 +668,9 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 80px;
min-height: 120px;
max-height: 520px;
overflow-y: auto;
}
.schedule-item {
@@ -523,12 +681,32 @@ onBeforeUnmount(() => {
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;
@@ -633,6 +811,23 @@ onBeforeUnmount(() => {
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;