增加日程提醒

This commit is contained in:
2026-08-28 16:58:17 +08:00
parent 9588ddbf31
commit d3bc1833f8
13 changed files with 2267 additions and 20 deletions
+1
View File
@@ -68,6 +68,7 @@ declare module 'vue' {
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
+49
View File
@@ -0,0 +1,49 @@
import request from '@/utils/request'
// OA 日程管理(日历待办)
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
export function getScheduleList(params) {
return request({
url: '/backend/oa/schedule/list',
method: 'get',
params
})
}
export function getScheduleStats() {
return request({
url: '/backend/oa/schedule/stats',
method: 'get'
})
}
export function createSchedule(data) {
return request({
url: '/backend/oa/schedule/create',
method: 'post',
data
})
}
export function updateSchedule(id, data) {
return request({
url: `/backend/oa/schedule/update/${id}`,
method: 'post',
data
})
}
export function deleteSchedule(id) {
return request({
url: `/backend/oa/schedule/delete/${id}`,
method: 'delete'
})
}
export function finishSchedule(id) {
return request({
url: `/backend/oa/schedule/finish/${id}`,
method: 'post'
})
}
+6
View File
@@ -110,6 +110,12 @@ const staticMainChildren = [
props: { module: "oa" },
meta: { requiresAuth: true, title: "职位管理", modulePath: "/apps/oa" }
},
{
path: "/apps/oa/schedule",
name: "OaSchedule",
component: () => import("@/views/apps/oa/schedule/index.vue"),
meta: { requiresAuth: true, title: "日程管理", modulePath: "/apps/oa" }
},
{
path: "/tools/passwordStore",
name: "BackendPasswordStore",
+256 -19
View File
@@ -35,30 +35,54 @@
</div>
</el-col>
<el-col :span="10" :xs="24">
<div class="chart-card">
<div class="chart-title">状态分布</div>
<div class="status-dist">
<div
v-for="(item, key) in dashboard.status_dist"
:key="key"
class="dist-row"
<div class="chart-card schedule-card">
<div class="chart-title schedule-title">
<span>工作日程提醒</span>
<el-button link type="primary" @click="goSchedule"
>查看全部</el-button
>
<el-tag :type="statusType(Number(key))" size="small">{{
item.name
}}</el-tag>
<div class="dist-bar-wrap">
</div>
<div v-loading="scheduleLoading" class="schedule-list">
<div
v-for="item in scheduleReminders"
:key="item.id"
class="schedule-item"
:class="{ done: item.status === 1 }"
:style="{ '--item-color': item.color }"
>
<el-checkbox
:model-value="item.status === 1"
@change="toggleScheduleFinish(item)"
/>
<div class="schedule-item-main" @click="toggleScheduleFinish(item)">
<div
class="dist-bar"
:style="{ width: distPercent(item.count) }"
/>
class="schedule-item-title"
:class="{ done: item.status === 1 }"
>
{{ item.title }}
<el-tag
v-if="item.priority > 0"
:type="item.priority === 2 ? 'danger' : 'warning'"
size="small"
effect="light"
>
{{ item.priority === 2 ? "紧急" : "重要" }}
</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>
<span class="dist-count"
>{{ item.count }} 单 / ¥ {{ money(item.amount) }}</span
>
</div>
<el-empty
v-if="!Object.keys(dashboard.status_dist).length"
description="暂无数据"
v-if="!scheduleReminders.length && !scheduleLoading"
description="近期暂无日程安排"
:image-size="60"
/>
</div>
@@ -66,6 +90,36 @@
</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>
@@ -94,9 +148,12 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } 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 { finishSchedule, getScheduleList } from "@/api/oaSchedule";
const emptyStat = () => ({ total: 0, paid: 0, unpaid: 0, count: 0 });
const dashboard = reactive({
@@ -187,6 +244,109 @@ const typePercent = (amount) =>
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
// ---------- 工作日程提醒 ----------
const router = useRouter();
const scheduleReminders = ref([]);
const scheduleLoading = ref(false);
const pad2 = (n) => String(n).padStart(2, "0");
const fmtDate = (d) =>
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
// 拉取逾期 + 未来7天日程(均含已完成,完成后灰显沉底而非从列表消失)
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}`,
);
});
} 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}`;
};
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 toggleScheduleFinish = async (item) => {
const res = await finishSchedule(item.id);
if (res?.code === 200) {
ElMessage.success(res.data?.status === 1 ? "已完成" : "已恢复待办");
loadScheduleReminders();
} else {
ElMessage.error(res?.msg || "操作失败");
}
};
const goSchedule = () => router.push("/apps/oa/schedule");
const loadDashboard = async () => {
try {
const data = responseData(await getReimbursementDashboard());
@@ -200,6 +360,7 @@ const loadDashboard = async () => {
console.warn("加载报销统计失败:", error?.message);
}
renderTrendChart();
loadScheduleReminders();
};
const handleResize = () => trendChart?.resize();
@@ -327,6 +488,82 @@ onBeforeUnmount(() => {
margin-bottom: 0;
}
.schedule-card {
height: 100%;
display: flex;
flex-direction: column;
.schedule-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.schedule-list {
flex: 1;
}
}
.status-card {
margin-bottom: 16px;
}
.schedule-list {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 80px;
}
.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;
&.done {
background: #f5f7fa;
border-left-color: #dcdfe6;
}
.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-item-time {
display: flex;
gap: 10px;
margin-top: 2px;
font-size: 12px;
color: #909399;
&.done {
color: #c0c4cc;
}
}
.overdue {
color: #f56c6c;
font-weight: 500;
}
}
.status-dist,
.type-dist {
display: flex;
@@ -0,0 +1,183 @@
<template>
<el-drawer v-model="visible" title="日程详情" size="420px">
<div v-if="schedule" class="detail-body">
<div class="detail-title-row">
<span class="color-chip" :style="{ background: schedule.color }" />
<h3 class="detail-title" :class="{ done: schedule.status === 1 }">
{{ schedule.title }}
</h3>
<el-tag
:type="schedule.status === 1 ? 'success' : 'warning'"
size="small"
>
{{ schedule.status === 1 ? "已完成" : "待办" }}
</el-tag>
</div>
<div class="detail-meta">
<div class="meta-row">
<span class="meta-label">日期</span>
<span>{{ schedule.schedule_date }} {{ weekLabel }}</span>
</div>
<div class="meta-row">
<span class="meta-label">时间</span>
<span>{{ timeText }}</span>
</div>
<div class="meta-row">
<span class="meta-label">优先级</span>
<span>
<el-tag
v-if="schedule.priority > 0"
:type="schedule.priority === 2 ? 'danger' : 'warning'"
size="small"
>
{{ schedule.priority === 2 ? "紧急" : "重要" }}
</el-tag>
<span v-else>普通</span>
</span>
</div>
<div class="meta-row">
<span class="meta-label">状态</span>
<span>{{ statusText }}</span>
</div>
<div v-if="schedule.content" class="meta-row">
<span class="meta-label">备注</span>
<span class="meta-content">{{ schedule.content }}</span>
</div>
<div class="meta-row">
<span class="meta-label">创建时间</span>
<span>{{ formatDateTime(schedule.create_time) }}</span>
</div>
</div>
</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
>
</template>
</el-drawer>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 展示的日程对象
schedule: {
type: Object,
default: null
}
});
const emit = defineEmits(["update:modelValue", "edit", "toggle"]);
const visible = computed({
get: () => props.modelValue,
set: value => emit("update:modelValue", value)
});
const weekLabel = computed(() => {
const parts = String(props.schedule?.schedule_date || "")
.split("-")
.map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return "";
}
const week = "日一二三四五六"[new Date(parts[0], parts[1] - 1, parts[2]).getDay()];
return `星期${week}`;
});
const timeText = computed(() => {
const s = props.schedule;
if (!s) {
return "-";
}
if (s.all_day === 1) {
return "全天";
}
return s.end_time ? `${s.start_time} - ${s.end_time}` : s.start_time || "-";
});
const statusText = computed(() => {
const s = props.schedule;
if (!s) {
return "-";
}
return s.status === 1
? `已完成${s.finish_time ? "(" + formatDateTime(s.finish_time) + ")" : ""}`
: "待办";
});
function formatDateTime(value) {
if (!value) {
return "-";
}
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
return String(value);
}
const pad = n => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
</script>
<style scoped>
.detail-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 18px;
}
.detail-title {
margin: 0;
font-size: 17px;
flex: 1;
word-break: break-all;
}
.detail-title.done {
text-decoration: line-through;
color: #c0c4cc;
}
.color-chip {
width: 12px;
height: 12px;
border-radius: 4px;
flex-shrink: 0;
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 14px;
}
.meta-row {
display: flex;
gap: 12px;
font-size: 14px;
color: #303133;
}
.meta-label {
width: 64px;
flex-shrink: 0;
color: #909399;
}
.meta-content {
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -0,0 +1,273 @@
<template>
<el-drawer v-model="visible" :title="isEdit ? '编辑日程' : '新建日程'" size="460px">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="日程标题" prop="title">
<el-input
v-model="form.title"
maxlength="80"
show-word-limit
placeholder="请输入日程标题"
/>
</el-form-item>
<el-form-item label="日程日期" prop="schedule_date">
<el-date-picker
v-model="form.schedule_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="全天">
<el-switch
v-model="form.all_day"
active-text="全天"
inactive-text="按时段"
/>
</el-form-item>
<el-form-item v-if="!form.all_day" label="时间段">
<div class="time-range">
<el-time-select
v-model="form.start_time"
start="00:00"
step="00:15"
end="23:45"
placeholder="开始时间"
class="time-input"
/>
<span class="time-sep">至</span>
<el-time-select
v-model="form.end_time"
start="00:00"
step="00:15"
end="23:45"
placeholder="结束(可选)"
class="time-input"
/>
</div>
</el-form-item>
<el-form-item label="优先级">
<el-radio-group v-model="form.priority">
<el-radio-button :value="0">普通</el-radio-button>
<el-radio-button :value="1">重要</el-radio-button>
<el-radio-button :value="2">紧急</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="标签颜色">
<div class="color-options">
<span
v-for="c in colorOptions"
:key="c"
class="color-dot"
:class="{ active: form.color === c }"
:style="{ background: c }"
@click="form.color = c"
/>
</div>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="form.content"
type="textarea"
:rows="4"
maxlength="2000"
show-word-limit
placeholder="补充说明(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave"
>保存</el-button
>
</template>
</el-drawer>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { createSchedule, updateSchedule } from "@/api/oaSchedule";
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 编辑时传入的日程对象;null 表示新建
schedule: {
type: Object,
default: null
},
// 新建时预填的日期(YYYY-MM-DD)
defaultDate: {
type: String,
default: ""
}
});
const emit = defineEmits(["update:modelValue", "saved"]);
const visible = computed({
get: () => props.modelValue,
set: value => emit("update:modelValue", value)
});
const colorOptions = [
"#409EFF",
"#0AD18E",
"#F5A623",
"#F56C6C",
"#9B6DF3",
"#909399"
];
const formRef = ref(null);
const saving = ref(false);
const editingId = ref(0);
const form = reactive({
title: "",
schedule_date: "",
all_day: true,
start_time: "",
end_time: "",
priority: 0,
color: colorOptions[0],
content: ""
});
const rules = {
title: [{ required: true, message: "请输入日程标题", trigger: "blur" }],
schedule_date: [
{ required: true, message: "请选择日程日期", trigger: "change" }
]
};
const isEdit = computed(() => !!props.schedule);
// 打开抽屉时根据编辑对象/默认日期重建表单。
// 用 watch 替代 el-drawer 的 @open + destroy-on-close:
// open 事件早于抽屉内容挂载,时序不稳定会导致表单控件状态错乱、点击无效。
watch(
() => props.modelValue,
open => {
if (open) {
initForm();
}
}
);
function initForm() {
if (props.schedule) {
editingId.value = props.schedule.id;
form.title = props.schedule.title;
form.schedule_date = props.schedule.schedule_date;
form.all_day = props.schedule.all_day === 1;
form.start_time = props.schedule.start_time || "";
form.end_time = props.schedule.end_time || "";
form.priority = props.schedule.priority || 0;
form.color = props.schedule.color || colorOptions[0];
form.content = props.schedule.content || "";
} else {
editingId.value = 0;
form.title = "";
form.schedule_date = props.defaultDate || "";
form.all_day = true;
form.start_time = "";
form.end_time = "";
form.priority = 0;
form.color = colorOptions[0];
form.content = "";
}
}
async function handleSave() {
try {
await formRef.value.validate();
} catch {
return;
}
if (!form.all_day) {
if (!form.start_time) {
ElMessage.warning("请选择开始时间");
return;
}
if (form.end_time && form.end_time < form.start_time) {
ElMessage.warning("结束时间不能早于开始时间");
return;
}
}
saving.value = true;
try {
const payload = {
title: form.title.trim(),
content: form.content,
schedule_date: form.schedule_date,
start_time: form.all_day ? "" : form.start_time,
end_time: form.all_day ? "" : form.end_time || "",
all_day: form.all_day ? 1 : 0,
color: form.color,
priority: form.priority
};
const res =
isEdit.value
? await updateSchedule(editingId.value, payload)
: await createSchedule(payload);
if (res?.code === 200) {
ElMessage.success(isEdit.value ? "日程已更新" : "日程已创建");
emit("saved");
visible.value = false;
} else {
ElMessage.error(res?.msg || "保存失败");
}
} finally {
saving.value = false;
}
}
</script>
<style scoped>
.time-range {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.time-input {
flex: 1;
min-width: 0;
}
.time-sep {
color: #909399;
}
.color-options {
display: flex;
align-items: center;
gap: 12px;
}
.color-dot {
width: 22px;
height: 22px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
transition: transform 0.15s;
}
.color-dot:hover {
transform: scale(1.12);
}
.color-dot.active {
border-color: #fff;
outline: 2px solid #409eff;
}
</style>
@@ -0,0 +1,991 @@
<template>
<div class="schedule-page">
<!-- 页头 -->
<div class="page-header">
<div>
<h2>日程管理</h2>
<p>按日历安排个人日程,跟进待办完成情况</p>
</div>
<div class="header-actions">
<el-radio-group v-model="viewMode">
<el-radio-button value="calendar">日历</el-radio-button>
<el-radio-button value="list">列表</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Plus" @click="openCreate()"
>新建日程</el-button
>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">今日待办</div>
<div class="stat-value">{{ stats.today_total || 0 }}</div>
<div class="stat-sub">已完成 {{ stats.today_done || 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">本周日程</div>
<div class="stat-value">{{ stats.week_total || 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">本月日程</div>
<div class="stat-value">{{ stats.month_total || 0 }}</div>
<div class="stat-sub">已完成 {{ stats.month_done || 0 }}</div>
</div>
<div class="stat-card" :class="{ danger: (stats.overdue || 0) > 0 }">
<div class="stat-label">逾期未完成</div>
<div class="stat-value">{{ stats.overdue || 0 }}</div>
</div>
</div>
<!-- 日历视图 -->
<div v-if="viewMode === 'calendar'" class="calendar-layout">
<div class="calendar-card">
<div class="calendar-toolbar">
<div class="toolbar-left">
<el-button :icon="ArrowLeft" circle @click="changeMonth(-1)" />
<span class="month-title"
>{{ calendarYear }} 年 {{ calendarMonth }} 月</span
>
<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>
</div>
<div class="calendar-grid">
<div
v-for="cell in calendarCells"
:key="cell.date"
class="calendar-cell"
:class="{
'other-month': !cell.inMonth,
today: cell.isToday,
selected: cell.date === selectedDate,
'has-items': cell.items.length > 0
}"
@click="selectedDate = cell.date"
@dblclick="openCreate(cell.date)"
>
<div class="cell-date">
<span class="day-num">{{ cell.day }}</span>
<span v-if="cell.isToday" class="today-tag">今</span>
<span v-else-if="cell.day === 1" class="month-tag"
>{{ cell.month }}月</span
>
</div>
<div class="cell-items">
<div
v-for="item in cell.items.slice(0, 3)"
:key="item.id"
class="cell-item"
:style="{ '--item-color': item.color }"
:title="item.title"
@click.stop="selectedDate = cell.date"
>
<span v-if="item.all_day === 0" class="item-time">{{
item.start_time
}}</span>
<span class="item-title" :class="{ done: item.status === 1 }">{{
item.title
}}</span>
</div>
<div
v-if="cell.items.length > 3"
class="cell-more"
@click.stop="selectedDate = cell.date"
>
还有 {{ cell.items.length - 3 }} 项
</div>
</div>
</div>
</div>
</div>
<!-- 选中日期侧栏 -->
<div class="day-panel">
<div class="day-panel-header">
<div>
<div class="day-title">{{ selectedDateLabel }}</div>
<div class="day-sub">{{ selectedItems.length }} 项日程</div>
</div>
<el-button
type="primary"
size="small"
:icon="Plus"
@click="openCreate(selectedDate)"
>新增</el-button
>
</div>
<div class="day-list">
<div
v-for="item in selectedItems"
:key="item.id"
class="day-item"
:style="{ '--item-color': item.color }"
>
<el-checkbox
:model-value="item.status === 1"
@change="toggleFinish(item)"
/>
<div class="day-item-main" @click="openDetail(item)">
<div class="day-item-title" :class="{ done: item.status === 1 }">
{{ item.title }}
<el-tag
v-if="item.priority > 0"
:type="priorityTagType(item.priority)"
size="small"
effect="light"
>
{{ priorityLabel(item.priority) }}
</el-tag>
</div>
<div class="day-item-time">{{ timeText(item) }}</div>
</div>
<el-dropdown
trigger="click"
@command="cmd => handleItemCommand(cmd, item)"
>
<el-button
text
:icon="MoreFilled"
class="day-item-more"
@click.stop
/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item command="delete" divided
>删除</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-empty
v-if="selectedItems.length === 0"
description="当天暂无日程"
:image-size="80"
/>
</div>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="table-card">
<div class="filter-bar">
<el-input
v-model="listFilters.keyword"
clearable
placeholder="搜索日程标题"
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"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
/>
<el-button type="primary" :icon="Search" @click="loadListData"
>查询</el-button
>
<el-button :icon="Refresh" @click="resetListFilters">重置</el-button>
</div>
<el-table v-loading="listLoading" :data="listData" stripe>
<el-table-column prop="schedule_date" label="日期" width="120" />
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ timeText(row) }}</template>
</el-table-column>
<el-table-column
prop="title"
label="标题"
min-width="220"
show-overflow-tooltip
>
<template #default="{ row }">
<span class="list-title" :class="{ done: row.status === 1 }">
<span
class="color-chip"
:style="{ background: row.color }"
/>{{ row.title }}
</span>
</template>
</el-table-column>
<el-table-column label="优先级" width="90">
<template #default="{ row }">
<el-tag
v-if="row.priority > 0"
:type="priorityTagType(row.priority)"
size="small"
>{{ priorityLabel(row.priority) }}</el-tag
>
<span v-else>普通</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
:type="row.status === 1 ? 'success' : 'warning'"
size="small"
>{{ row.status === 1 ? "已完成" : "待办" }}</el-tag
>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="toggleFinish(row)">{{
row.status === 1 ? "取消完成" : "完成"
}}</el-button>
<el-button link type="primary" @click="openEdit(row)"
>编辑</el-button
>
<el-button link type="danger" @click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
<template #empty><el-empty description="暂无日程" /></template>
</el-table>
<div class="pagination">
<el-pagination
:current-page="listPagination.page"
:page-size="listPagination.pageSize"
:total="listPagination.total"
layout="total, sizes, prev, pager, next, jumper"
@update:current-page="listPagination.page = $event"
@update:page-size="listPagination.pageSize = $event"
@current-change="loadListData"
@size-change="loadListData"
/>
</div>
</div>
<!-- 新增/编辑抽屉 -->
<ScheduleEdit
v-model="editVisible"
:schedule="editingItem"
:default-date="selectedDate"
@saved="reloadAll"
/>
<!-- 日程详情抽屉 -->
<ScheduleDetail
v-model="detailVisible"
:schedule="detailItem"
@edit="handleDetailEdit"
@toggle="toggleFromDetail"
/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
ArrowLeft,
ArrowRight,
MoreFilled,
Plus,
Refresh,
Search
} from "@element-plus/icons-vue";
import ScheduleEdit from "./components/edit.vue";
import ScheduleDetail from "./components/detail.vue";
import {
deleteSchedule,
finishSchedule,
getScheduleList,
getScheduleStats
} from "@/api/oaSchedule";
const viewMode = ref("calendar");
const stats = ref({});
// ---------- 日期工具(避免额外依赖) ----------
function pad2(n) {
return String(n).padStart(2, "0");
}
function fmtDate(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function todayStr() {
return fmtDate(new Date());
}
// 后端 DSN 开启 parseTime 时 DATE 字段可能返回 Go 默认格式
// ("2026-08-29 00:00:00 +0800 CST"),统一截取为 YYYY-MM-DD,
// 保证日历分组与选中日期侧栏能正确匹配。
function normDate(value) {
return typeof value === "string" && value.length >= 10
? value.slice(0, 10)
: value || "";
}
// ---------- 日历视图 ----------
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());
// 日历 42 格(6 行 x 7 列,周一起始)
const calendarCells = computed(() => {
const map = calendarMap.value;
const today = todayStr();
const start = calendarRangeStart();
const cells = [];
for (let i = 0; i < 42; i++) {
const d = new Date(
start.getFullYear(),
start.getMonth(),
start.getDate() + i
);
const date = fmtDate(d);
cells.push({
date,
day: d.getDate(),
month: d.getMonth() + 1,
inMonth: d.getMonth() + 1 === calendarMonth.value,
isToday: date === today,
items: map[date] || []
});
}
return cells;
});
// 当前月历网格的起始日期(含上月补齐)
function calendarRangeStart() {
const first = new Date(calendarYear.value, calendarMonth.value - 1, 1);
const offset = (first.getDay() + 6) % 7;
return new Date(calendarYear.value, calendarMonth.value - 1, 1 - offset);
}
// 按日期分组:{ 'YYYY-MM-DD': [日程...] }
const calendarMap = computed(() => {
const map = {};
for (const item of calendarItems.value) {
if (!map[item.schedule_date]) {
map[item.schedule_date] = [];
}
map[item.schedule_date].push(item);
}
return map;
});
const selectedItems = computed(
() => calendarMap.value[selectedDate.value] || []
);
const selectedDateLabel = computed(() => {
const [y, m, d] = selectedDate.value.split("-").map(Number);
const week = "日一二三四五六"[new Date(y, m - 1, d).getDay()];
return `${m}月${d}日 星期${week}`;
});
async function loadCalendarData() {
// 拉取整个 42 格区间的数据,一次覆盖前后补齐的日期
const start = calendarRangeStart();
const end = new Date(
start.getFullYear(),
start.getMonth(),
start.getDate() + 41
);
const res = await getScheduleList({
start_date: fmtDate(start),
end_date: fmtDate(end),
page: 1,
pageSize: 500,
status: calendarStatus.value
});
const data = res?.data || {};
calendarItems.value = (data.list || []).map(item => ({
...item,
schedule_date: normDate(item.schedule_date)
}));
}
function changeMonth(delta) {
const d = new Date(calendarYear.value, calendarMonth.value - 1 + delta, 1);
calendarYear.value = d.getFullYear();
calendarMonth.value = d.getMonth() + 1;
loadCalendarData();
}
function goToday() {
const d = new Date();
calendarYear.value = d.getFullYear();
calendarMonth.value = d.getMonth() + 1;
selectedDate.value = todayStr();
loadCalendarData();
}
// ---------- 统计 ----------
async function loadStats() {
const res = await getScheduleStats();
if (res?.code === 200) {
stats.value = res.data || {};
}
}
// ---------- 列表视图 ----------
const listFilters = reactive({ keyword: "", status: "", range: null });
const listData = ref([]);
const listLoading = ref(false);
const listPagination = reactive({ page: 1, pageSize: 20, total: 0 });
async function loadListData() {
listLoading.value = true;
try {
const params = {
page: listPagination.page,
pageSize: listPagination.pageSize,
keyword: listFilters.keyword,
status: listFilters.status
};
if (listFilters.range && listFilters.range.length === 2) {
params.start_date = listFilters.range[0];
params.end_date = listFilters.range[1];
}
const res = await getScheduleList(params);
const data = res?.data || {};
listData.value = (data.list || []).map(item => ({
...item,
schedule_date: normDate(item.schedule_date)
}));
listPagination.total = data.total || 0;
} finally {
listLoading.value = false;
}
}
function resetListFilters() {
listFilters.keyword = "";
listFilters.status = "";
listFilters.range = null;
listPagination.page = 1;
loadListData();
}
// 切到列表视图时按需加载
watch(viewMode, mode => {
if (mode === "list" && listData.value.length === 0) {
loadListData();
}
});
// ---------- 详情/新增/编辑/操作 ----------
const editVisible = ref(false);
const editingItem = ref(null);
const detailVisible = ref(false);
const detailItem = ref(null);
function openCreate(date) {
editingItem.value = null;
if (date) {
selectedDate.value = date;
}
editVisible.value = true;
}
function openEdit(item) {
editingItem.value = item;
editVisible.value = true;
}
function openDetail(item) {
detailItem.value = item;
detailVisible.value = true;
}
// 详情里点编辑:关闭详情后打开编辑抽屉
function handleDetailEdit(item) {
detailVisible.value = false;
openEdit(item);
}
// 详情内完成/取消完成后从最新数据重新定位,保持详情内容同步
async function toggleFromDetail(item) {
await toggleFinish(item);
detailItem.value = calendarItems.value.find(i => i.id === item.id) || null;
if (!detailItem.value) {
detailVisible.value = false;
}
}
function handleItemCommand(cmd, item) {
if (cmd === "edit") {
openEdit(item);
} else if (cmd === "delete") {
handleDelete(item);
}
}
async function toggleFinish(item) {
const res = await finishSchedule(item.id);
if (res?.code === 200) {
ElMessage.success(res.data?.status === 1 ? "已完成" : "已恢复待办");
return reloadAll();
} else {
ElMessage.error(res?.msg || "操作失败");
}
}
async function handleDelete(item) {
try {
await ElMessageBox.confirm(
`确定删除日程「${item.title}」吗?`,
"删除确认",
{ type: "warning", confirmButtonText: "删除", cancelButtonText: "取消" }
);
} catch {
return;
}
const res = await deleteSchedule(item.id);
if (res?.code === 200) {
ElMessage.success("删除成功");
reloadAll();
} else {
ElMessage.error(res?.msg || "删除失败");
}
}
function reloadAll() {
const tasks = [loadStats(), loadCalendarData()];
if (viewMode.value === "list") {
tasks.push(loadListData());
}
return Promise.all(tasks);
}
// ---------- 展示辅助 ----------
function timeText(item) {
if (item.all_day === 1) {
return "全天";
}
return item.end_time
? `${item.start_time} - ${item.end_time}`
: item.start_time;
}
function priorityLabel(priority) {
return { 1: "重要", 2: "紧急" }[priority] || "普通";
}
function priorityTagType(priority) {
return { 1: "warning", 2: "danger" }[priority] || "info";
}
onMounted(() => {
loadStats();
loadCalendarData();
});
</script>
<style scoped>
.schedule-page {
padding: 16px 20px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-header h2 {
margin: 0 0 4px;
font-size: 20px;
}
.page-header p {
margin: 0;
color: #909399;
font-size: 13px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* 统计卡片 */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 14px 18px;
}
.stat-label {
font-size: 13px;
color: #909399;
}
.stat-value {
font-size: 26px;
font-weight: 600;
margin: 4px 0 2px;
}
.stat-card.danger .stat-value {
color: #f56c6c;
}
.stat-sub {
font-size: 12px;
color: #c0c4cc;
}
/* 日历视图布局 */
.calendar-layout {
display: flex;
gap: 16px;
align-items: stretch;
}
.calendar-card {
flex: 1;
min-width: 0;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 16px;
}
.calendar-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
}
.month-title {
font-size: 17px;
font-weight: 600;
min-width: 120px;
text-align: center;
}
.calendar-week-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-bottom: 1px solid #ebeef5;
}
.week-name {
padding: 8px 0;
text-align: center;
font-size: 13px;
color: #909399;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: minmax(96px, auto);
}
.calendar-cell {
border-right: 1px solid #f2f4f8;
border-bottom: 1px solid #f2f4f8;
padding: 6px;
cursor: pointer;
transition: background 0.15s;
overflow: hidden;
}
.calendar-cell:nth-child(7n) {
border-right: none;
}
.calendar-cell:hover {
background: #f5f8ff;
}
.calendar-cell.other-month {
color: #c0c4cc;
background: #fafbfc;
}
.calendar-cell.selected {
background: #ecf5ff;
box-shadow: inset 0 0 0 2px #409eff;
border-radius: 4px;
}
.cell-date {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.day-num {
font-size: 14px;
font-weight: 500;
}
.calendar-cell.today .day-num {
background: #409eff;
color: #fff;
border-radius: 50%;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.today-tag,
.month-tag {
font-size: 11px;
color: #409eff;
}
.cell-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.cell-item {
display: flex;
align-items: center;
gap: 4px;
padding: 1px 4px 1px 6px;
border-left: 3px solid var(--item-color);
background: #f5f7fa;
border-radius: 3px;
font-size: 12px;
line-height: 20px;
overflow: hidden;
white-space: nowrap;
}
.cell-item:hover {
background: #ebeef5;
}
.item-time {
color: #909399;
flex-shrink: 0;
}
.item-title {
overflow: hidden;
text-overflow: ellipsis;
}
.item-title.done {
text-decoration: line-through;
color: #c0c4cc;
}
.cell-more {
font-size: 12px;
color: #909399;
padding-left: 6px;
}
.cell-more:hover {
color: #409eff;
}
/* 选中日期侧栏 */
.day-panel {
width: 320px;
flex-shrink: 0;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
}
.day-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #f2f4f8;
}
.day-title {
font-size: 16px;
font-weight: 600;
}
.day-sub {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.day-list {
flex: 1;
overflow-y: auto;
margin-top: 8px;
}
.day-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 8px;
border-left: 3px solid var(--item-color);
border-radius: 4px;
background: #fafbfc;
margin-bottom: 8px;
}
.day-item-main {
flex: 1;
min-width: 0;
cursor: pointer;
}
.day-item-title {
font-size: 14px;
line-height: 1.4;
word-break: break-all;
}
.day-item-title.done {
text-decoration: line-through;
color: #c0c4cc;
}
.day-item-time {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.day-item-more {
flex-shrink: 0;
}
/* 列表视图 */
.table-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 16px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.list-title {
display: inline-flex;
align-items: center;
gap: 6px;
}
.list-title.done {
text-decoration: line-through;
color: #c0c4cc;
}
.color-chip {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
/* 响应式 */
@media (max-width: 1200px) {
.calendar-layout {
flex-direction: column;
}
.day-panel {
width: 100%;
}
}
@media (max-width: 768px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
.calendar-grid {
grid-auto-rows: minmax(64px, auto);
}
.cell-items {
display: none;
}
.calendar-cell {
position: relative;
}
.calendar-cell.has-items::after {
content: "";
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: #409eff;
}
}
</style>