优化日程提醒模块

This commit is contained in:
hero920103 committed 2026-09-08 22:18:36 +08:00
1 parent 3d53f03246
commit 7f3de60423
17 files changed
+1516 -270

No files matched your search

@@ -39,6 +39,10 @@
<span class="meta-label">状态</span>
<span>{{ statusText }}</span>
</div>
<div v-if="carryText" class="meta-row">
<span class="meta-label">延期</span>
<span>{{ carryText }}</span>
</div>
<div v-if="schedule.content" class="meta-row">
<span class="meta-label">备注</span>
<span class="meta-content">{{ schedule.content }}</span>
@@ -51,10 +55,31 @@
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-button @click="emit('toggle', schedule)">{{
schedule?.status === 1 ? "取消完成" : "完成"
}}</el-button>
<el-button type="primary" @click="emit('edit', schedule)"
<!-- 待办:可选择延期或标记已完成;已完成:只能反审核回待办 -->
<el-button
v-if="schedule?.status !== 1"
:icon="Promotion"
@click="emit('postpone', schedule)"
>延期</el-button
>
<el-button
v-if="schedule?.status !== 1"
type="success"
:icon="CircleCheck"
@click="emit('finish', schedule)"
>已完成</el-button
>
<el-button
v-else
type="warning"
:icon="RefreshLeft"
@click="emit('revert', schedule)"
>反审核</el-button
>
<el-button
v-if="editable"
type="primary"
@click="emit('edit', schedule)"
>编辑</el-button
>
</template>
@@ -63,6 +88,11 @@
<script setup>
import { computed } from "vue";
import {
CircleCheck,
Promotion,
RefreshLeft
} from "@element-plus/icons-vue";
const props = defineProps({
modelValue: {
@@ -73,10 +103,21 @@ const props = defineProps({
schedule: {
type: Object,
default: null
},
// 是否显示"编辑"按钮(仪表盘只读场景传 false)
editable: {
type: Boolean,
default: true
}
});
const emit = defineEmits(["update:modelValue", "edit", "toggle"]);
const emit = defineEmits([
"update:modelValue",
"edit",
"finish",
"revert",
"postpone"
]);
const visible = computed({
get: () => props.modelValue,
@@ -105,6 +146,21 @@ const timeText = computed(() => {
return s.end_time ? `${s.start_time} - ${s.end_time}` : s.start_time || "-";
});
// 延期说明:已完成的是被延期出去的原日程,未完成的是被延期出来的续做日程
const carryText = computed(() => {
const s = props.schedule;
if (!s) {
return "";
}
if (s.carry_over === 1) {
return `已延期至 ${s.carry_to_date || "下一天"}`;
}
if (s.source_id > 0) {
return `由前序日程延期而来,已延期 ${s.carry_count || 0} 次`;
}
return "";
});
const statusText = computed(() => {
const s = props.schedule;
if (!s) {
@@ -0,0 +1,187 @@
<template>
<el-dialog v-model="visible" title="延期日程" width="440px" append-to-body>
<div v-if="schedule" class="postpone-body">
<p class="postpone-tip">
<span v-if="overdueDays > 0" class="postpone-overdue"
>该日程已逾期 {{ overdueDays }} 天。</span
>
延期后「{{ schedule.title }}」在 {{ schedule.schedule_date }}
当天标记为已完成,并在目标日期自动生成一条待办继续跟进。
</p>
<el-form label-position="top">
<el-form-item label="延期到">
<el-date-picker
v-model="targetDate"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
:disabled-date="disabledDate"
style="width: 100%"
/>
</el-form-item>
</el-form>
<div class="postpone-quick">
<el-button
v-for="opt in quickOptions"
:key="opt.label"
size="small"
@click="targetDate = addDays(baseDate, opt.days)"
>{{ opt.label }}</el-button
>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit"
>确定延期</el-button
>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { carrySchedule } from "@/api/oaSchedule";
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 需要延期的日程对象
schedule: {
type: Object,
default: null
}
});
const emit = defineEmits(["update:modelValue", "saved"]);
const visible = computed({
get: () => props.modelValue,
set: value => emit("update:modelValue", value)
});
const pad2 = n => String(n).padStart(2, "0");
const fmtDate = d =>
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
// 后端 DSN 开启 parseTime 时日期可能带时间部分,统一截取前 10 位
function addDays(dateStr, days) {
const parts = String(dateStr || "")
.slice(0, 10)
.split("-")
.map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return "";
}
return fmtDate(new Date(parts[0], parts[1] - 1, parts[2] + days));
}
const quickOptions = computed(() => {
const d = String(props.schedule?.schedule_date || "").slice(0, 10);
const today = fmtDate(new Date());
// 逾期任务以"今天"为基准,快捷项给出今天/明天/下周
if (d && d < today) {
return [
{ label: "今天", days: 0 },
{ label: "明天", days: 1 },
{ label: "下周", days: 7 }
];
}
return [
{ label: "明天", days: 1 },
{ label: "后天", days: 2 },
{ label: "下周", days: 7 }
];
});
// 快捷键的基准日期:逾期任务按今天算,未到期任务按自身日期算
const baseDate = computed(() => {
const today = fmtDate(new Date());
const d = String(props.schedule?.schedule_date || "").slice(0, 10);
return d > today ? d : today;
});
// 已逾期天数,用于提示(未逾期为 0)
const overdueDays = computed(() => {
const d = String(props.schedule?.schedule_date || "").slice(0, 10);
if (!d || props.schedule?.status === 1) {
return 0;
}
const today = fmtDate(new Date());
if (d >= today) {
return 0;
}
return Math.round((new Date(today) - new Date(d)) / 86400000);
});
const targetDate = ref("");
const saving = ref(false);
// 打开时给出默认日期:逾期任务默认补到今天,未到期任务默认顺延到下一天
watch(
() => props.modelValue,
open => {
if (!open) {
return;
}
const today = fmtDate(new Date());
const d = String(props.schedule?.schedule_date || "").slice(0, 10);
targetDate.value = d && d < today ? today : addDays(d, 1);
}
);
// 只能延期到当前日程日期之后
function disabledDate(date) {
if (!props.schedule) {
return false;
}
return fmtDate(date) <= String(props.schedule.schedule_date || "").slice(0, 10);
}
async function submit() {
if (!props.schedule || !targetDate.value) {
return;
}
saving.value = true;
try {
const res = await carrySchedule(props.schedule.id, {
target_date: targetDate.value
});
if (res?.code === 200) {
ElMessage.success(`已延期到 ${targetDate.value}`);
visible.value = false;
emit("saved");
} else {
ElMessage.error(res?.msg || "延期失败");
}
} finally {
saving.value = false;
}
}
</script>
<style scoped>
.postpone-tip {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.6;
color: #606266;
background: #f5f7fa;
border-radius: 6px;
padding: 10px 12px;
}
.postpone-overdue {
color: #f56c6c;
font-weight: 500;
}
.postpone-quick {
display: flex;
align-items: center;
gap: 8px;
}
</style>
+250 -40
View File
@@ -39,6 +39,26 @@
</div>
</div>
<!-- 待办分类 tabs -->
<el-tabs
v-model="category"
class="category-tabs"
@tab-change="handleCategoryChange"
>
<el-tab-pane
v-for="tab in categoryTabs"
:key="tab.value"
:name="tab.value"
>
<template #label>
<span class="tab-label">
{{ tab.label }}
<span class="tab-count">{{ categoryCounts[tab.value] || 0 }}</span>
</span>
</template>
</el-tab-pane>
</el-tabs>
<!-- 日历视图 -->
<div v-if="viewMode === 'calendar'" class="calendar-layout">
<div class="calendar-card">
@@ -51,15 +71,6 @@
<el-button :icon="ArrowRight" circle @click="changeMonth(1)" />
<el-button @click="goToday">今天</el-button>
</div>
<el-radio-group
v-model="calendarStatus"
size="small"
@change="loadCalendarData"
>
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="0">待办</el-radio-button>
<el-radio-button value="1">已完成</el-radio-button>
</el-radio-group>
</div>
<div class="calendar-week-header">
<div v-for="w in weekNames" :key="w" class="week-name">{{ w }}</div>
@@ -118,15 +129,28 @@
<div class="day-panel-header">
<div>
<div class="day-title">{{ selectedDateLabel }}</div>
<div class="day-sub">{{ selectedItems.length }} 项日程</div>
<div class="day-sub">
{{ selectedItems.length }} 项日程<template v-if="pendingCount > 0"
>,{{ pendingCount }} 项未完成</template
>
</div>
</div>
<div class="panel-actions">
<el-button
v-if="pendingCount > 0"
size="small"
:icon="Promotion"
@click="handleCarryPending"
>延期未完成</el-button
>
<el-button
type="primary"
size="small"
:icon="Plus"
@click="openCreate(selectedDate)"
>新增</el-button
>
</div>
<el-button
type="primary"
size="small"
:icon="Plus"
@click="openCreate(selectedDate)"
>新增</el-button
>
</div>
<div class="day-list">
<div
@@ -150,6 +174,22 @@
>
{{ priorityLabel(item.priority) }}
</el-tag>
<el-tag
v-if="item.carry_over === 1"
size="small"
effect="light"
type="info"
>
已延期至 {{ shortDate(item.carry_to_date) }}
</el-tag>
<el-tag
v-else-if="item.source_id > 0"
size="small"
effect="light"
type="warning"
>
延期第 {{ item.carry_count + 1 }} 天
</el-tag>
</div>
<div class="day-item-time">{{ timeText(item) }}</div>
</div>
@@ -165,6 +205,11 @@
/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="postpone"
:disabled="item.status === 1"
>延期到下一天</el-dropdown-item
>
<el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item command="delete" divided
>删除</el-dropdown-item
@@ -192,15 +237,6 @@
style="width: 200px"
@keyup.enter="loadListData"
/>
<el-select
v-model="listFilters.status"
clearable
placeholder="全部状态"
style="width: 120px"
>
<el-option label="待办" value="0" />
<el-option label="已完成" value="1" />
</el-select>
<el-date-picker
v-model="listFilters.range"
type="daterange"
@@ -254,11 +290,18 @@
>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="toggleFinish(row)">{{
row.status === 1 ? "取消完成" : "完成"
}}</el-button>
<el-button
link
type="primary"
:disabled="row.status === 1"
@click="openPostpone(row)"
>延期</el-button
>
<el-button link type="primary" @click="openEdit(row)"
>编辑</el-button
>
@@ -296,7 +339,16 @@
v-model="detailVisible"
:schedule="detailItem"
@edit="handleDetailEdit"
@toggle="toggleFromDetail"
@finish="handleDetailFinish"
@revert="handleDetailRevert"
@postpone="handleDetailPostpone"
/>
<!-- 延期对话框 -->
<SchedulePostpone
v-model="postponeVisible"
:schedule="postponeItem"
@saved="reloadAll"
/>
</div>
</template>
@@ -309,12 +361,15 @@ import {
ArrowRight,
MoreFilled,
Plus,
Promotion,
Refresh,
Search
} from "@element-plus/icons-vue";
import ScheduleEdit from "./components/edit.vue";
import ScheduleDetail from "./components/detail.vue";
import SchedulePostpone from "./components/postponeDialog.vue";
import {
carryPendingSchedules,
deleteSchedule,
finishSchedule,
getScheduleList,
@@ -324,6 +379,19 @@ import {
const viewMode = ref("calendar");
const stats = ref({});
// ---------- 待办分类 tabs ----------
// all-全部 today-今日 pending-未完成 done-已完成
// 注:后端仍支持 ongoing(进行中 = 跨天延期中的任务),当前不启用;
// 以后要恢复,在下面数组里加一行 { value: "ongoing", label: "进行中" } 即可。
const categoryTabs = [
{ value: "all", label: "全部" },
{ value: "today", label: "今日" },
{ value: "pending", label: "未完成" },
{ value: "done", label: "已完成" }
];
const category = ref("all");
const categoryCounts = ref({});
// ---------- 日期工具(避免额外依赖) ----------
function pad2(n) {
return String(n).padStart(2, "0");
@@ -346,12 +414,23 @@ function normDate(value) {
: value || "";
}
// 在 YYYY-MM-DD 上加减天数,用于计算"顺延到下一天"的目标日期
function addDays(dateStr, days) {
const parts = String(dateStr || "")
.slice(0, 10)
.split("-")
.map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return "";
}
return fmtDate(new Date(parts[0], parts[1] - 1, parts[2] + days));
}
// ---------- 日历视图 ----------
const weekNames = ["一", "二", "三", "四", "五", "六", "日"];
const now = new Date();
const calendarYear = ref(now.getFullYear());
const calendarMonth = ref(now.getMonth() + 1);
const calendarStatus = ref("");
const calendarItems = ref([]);
const selectedDate = ref(todayStr());
@@ -403,6 +482,11 @@ const selectedItems = computed(
() => calendarMap.value[selectedDate.value] || []
);
// 当天仍未完成的日程数,用于"顺延未完成"批量操作
const pendingCount = computed(
() => selectedItems.value.filter(item => item.status !== 1).length
);
const selectedDateLabel = computed(() => {
const [y, m, d] = selectedDate.value.split("-").map(Number);
const week = "日一二三四五六"[new Date(y, m - 1, d).getDay()];
@@ -422,9 +506,12 @@ async function loadCalendarData() {
end_date: fmtDate(end),
page: 1,
pageSize: 500,
status: calendarStatus.value
category: category.value
});
const data = res?.data || {};
if (data.counts) {
categoryCounts.value = data.counts;
}
calendarItems.value = (data.list || []).map(item => ({
...item,
schedule_date: normDate(item.schedule_date)
@@ -455,7 +542,7 @@ async function loadStats() {
}
// ---------- 列表视图 ----------
const listFilters = reactive({ keyword: "", status: "", range: null });
const listFilters = reactive({ keyword: "", range: null });
const listData = ref([]);
const listLoading = ref(false);
const listPagination = reactive({ page: 1, pageSize: 20, total: 0 });
@@ -467,7 +554,7 @@ async function loadListData() {
page: listPagination.page,
pageSize: listPagination.pageSize,
keyword: listFilters.keyword,
status: listFilters.status
category: category.value
};
if (listFilters.range && listFilters.range.length === 2) {
params.start_date = listFilters.range[0];
@@ -475,6 +562,9 @@ async function loadListData() {
}
const res = await getScheduleList(params);
const data = res?.data || {};
if (data.counts) {
categoryCounts.value = data.counts;
}
listData.value = (data.list || []).map(item => ({
...item,
schedule_date: normDate(item.schedule_date)
@@ -487,12 +577,21 @@ async function loadListData() {
function resetListFilters() {
listFilters.keyword = "";
listFilters.status = "";
listFilters.range = null;
listPagination.page = 1;
loadListData();
}
// 切换分类:切到"今日"时日历自动回到今天所在月份,避免当月看不到数据
function handleCategoryChange(value) {
listPagination.page = 1;
if (value === "today" && viewMode.value === "calendar") {
goToday();
return;
}
reloadAll();
}
// 切到列表视图时按需加载
watch(viewMode, mode => {
if (mode === "list" && listData.value.length === 0) {
@@ -530,23 +629,79 @@ function handleDetailEdit(item) {
openEdit(item);
}
// 详情内完成/取消完成后从最新数据重新定位,保持详情内容同步
async function toggleFromDetail(item) {
// 详情里点"已完成"
async function handleDetailFinish(item) {
detailVisible.value = false;
await toggleFinish(item);
detailItem.value = calendarItems.value.find(i => i.id === item.id) || null;
if (!detailItem.value) {
detailVisible.value = false;
}
}
// 详情里点"反审核":把已完成的日程恢复为待办
async function handleDetailRevert(item) {
detailVisible.value = false;
await toggleFinish(item);
}
// 详情里点"延期":关闭详情后打开延期对话框
function handleDetailPostpone(item) {
detailVisible.value = false;
openPostpone(item);
}
function handleItemCommand(cmd, item) {
if (cmd === "edit") {
if (cmd === "postpone") {
openPostpone(item);
} else if (cmd === "edit") {
openEdit(item);
} else if (cmd === "delete") {
handleDelete(item);
}
}
// ---------- 延期到下一天 ----------
const postponeVisible = ref(false);
const postponeItem = ref(null);
function openPostpone(item) {
if (!item || item.status === 1) {
ElMessage.warning("该日程已完成,无需延期");
return;
}
postponeItem.value = item;
postponeVisible.value = true;
}
// 批量延期:把选中日期的全部未完成日程一次性延期到下一天
async function handleCarryPending() {
if (pendingCount.value === 0) {
return;
}
// 目标日期取"选定日期+1天",但不早于今天,避免把过去的任务再延到过去
const nextDay = addDays(selectedDate.value, 1);
const target = nextDay < todayStr() ? todayStr() : nextDay;
try {
await ElMessageBox.confirm(
`将 ${selectedDate.value} 的 ${pendingCount.value} 条未完成日程延期到 ${target}?当天这些日程会标记为已完成。`,
"批量延期",
{ type: "warning", confirmButtonText: "延期", cancelButtonText: "取消" }
);
} catch {
return;
}
const res = await carryPendingSchedules({
date: selectedDate.value,
target_date: target
});
if (res?.code === 200) {
const d = res.data || {};
ElMessage.success(
`已延期 ${d.success || 0} 条${d.skipped ? `,跳过 ${d.skipped} 条` : ""}`
);
await reloadAll();
} else {
ElMessage.error(res?.msg || "延期失败");
}
}
async function toggleFinish(item) {
const res = await finishSchedule(item.id);
if (res?.code === 200) {
@@ -585,6 +740,18 @@ function reloadAll() {
}
// ---------- 展示辅助 ----------
// YYYY-MM-DD -> M月D日,用于"已顺延至"标签
function shortDate(value) {
const parts = String(value || "")
.slice(0, 10)
.split("-")
.map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return value || "";
}
return `${parts[1]}月${parts[2]}日`;
}
function timeText(item) {
if (item.all_day === 1) {
return "全天";
@@ -672,6 +839,42 @@ onMounted(() => {
color: #c0c4cc;
}
/* 待办分类 tabs */
.category-tabs {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 0 16px;
margin-bottom: 16px;
}
.category-tabs :deep(.el-tabs__header) {
margin: 0;
}
/* 分类内容由下方日历/列表承载,tabs 只作切换栏,隐藏空的面板容器 */
.category-tabs :deep(.el-tabs__content) {
display: none;
}
.tab-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.tab-count {
min-width: 20px;
padding: 0 6px;
height: 18px;
line-height: 18px;
border-radius: 9px;
background: #f0f2f5;
color: #909399;
font-size: 12px;
text-align: center;
}
/* 日历视图布局 */
.calendar-layout {
display: flex;
@@ -864,6 +1067,13 @@ onMounted(() => {
margin-top: 2px;
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.day-list {
flex: 1;
overflow-y: auto;