优化日程提醒模块
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user