增加日程提醒
This commit is contained in:
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
appname = server
|
||||
appname = server
|
||||
httpport = 9000
|
||||
runmode = dev
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendOaScheduleController OA日程管理(日历待办)。
|
||||
// 日程数据按 JWT 中的租户与用户双重隔离,仅创建者本人可见与操作。
|
||||
type BackendOaScheduleController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *BackendOaScheduleController) oaScheduleClaims() (*jwtutil.Claims, error) {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims.UserType != "backend" {
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (c *BackendOaScheduleController) oasJsonErr(httpStatus, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendOaScheduleController) oasOk(data interface{}) {
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
var (
|
||||
oasDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
oasTimePattern = regexp.MustCompile(`^\d{2}:\d{2}$`)
|
||||
)
|
||||
|
||||
// isValidOaDate 校验 "YYYY-MM-DD" 日期字符串(含真实日历合法性,如 2 月 30 日)。
|
||||
func isValidOaDate(s string) bool {
|
||||
if !oasDatePattern.MatchString(s) {
|
||||
return false
|
||||
}
|
||||
_, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// isValidOaTime 校验 "HH:mm" 时间字符串;HH:mm 定长数字串的字典序即时间序。
|
||||
func isValidOaTime(s string) bool {
|
||||
if !oasTimePattern.MatchString(s) {
|
||||
return false
|
||||
}
|
||||
_, err := time.ParseInLocation("15:04", s, time.Local)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// normalizeOaDate 将 ORM 读出的日期值统一为 "YYYY-MM-DD"。
|
||||
// DSN 开启 parseTime=True 后 DATE 列会被驱动解析为 time.Time,
|
||||
// 赋给 string 字段后是 Go 默认格式("2026-08-29 00:00:00 +0800 CST"),取前 10 位。
|
||||
func normalizeOaDate(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 10 {
|
||||
return s[:10]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// List GET /backend/oa/schedule/list
|
||||
// 分页查询当前用户的日程,支持 keyword/status/priority/start_date/end_date 筛选。
|
||||
// 日历视图传 start_date/end_date 且 pageSize 足够大时一次拉取区间全量数据。
|
||||
func (c *BackendOaScheduleController) List() {
|
||||
claims, err := c.oaScheduleClaims()
|
||||
if err != nil {
|
||||
c.oasJsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
status := strings.TrimSpace(c.GetString("status"))
|
||||
priority := strings.TrimSpace(c.GetString("priority"))
|
||||
startDate := strings.TrimSpace(c.GetString("start_date"))
|
||||
endDate := strings.TrimSpace(c.GetString("end_date"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 500 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.OaSchedule)).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("user_id", claims.UserID)
|
||||
|
||||
if keyword != "" {
|
||||
qs = qs.Filter("title__icontains", keyword)
|
||||
}
|
||||
if status == "0" || status == "1" {
|
||||
statusVal, _ := strconv.Atoi(status)
|
||||
qs = qs.Filter("status", int8(statusVal))
|
||||
}
|
||||
if priority == "0" || priority == "1" || priority == "2" {
|
||||
priorityVal, _ := strconv.Atoi(priority)
|
||||
qs = qs.Filter("priority", int8(priorityVal))
|
||||
}
|
||||
if isValidOaDate(startDate) {
|
||||
qs = qs.Filter("schedule_date__gte", startDate)
|
||||
}
|
||||
if isValidOaDate(endDate) {
|
||||
qs = qs.Filter("schedule_date__lte", endDate)
|
||||
}
|
||||
|
||||
total, _ := qs.Count()
|
||||
|
||||
var list []models.OaSchedule
|
||||
_, err = qs.OrderBy("schedule_date", "start_time", "-id").
|
||||
Limit(pageSize).Offset((page - 1) * pageSize).
|
||||
All(&list)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.oasJsonErr(500, 500, "查询失败")
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []models.OaSchedule{}
|
||||
}
|
||||
|
||||
// 统一日期格式,前端日历分组与日期标签均依赖 "YYYY-MM-DD"。
|
||||
for i := range list {
|
||||
list[i].ScheduleDate = normalizeOaDate(list[i].ScheduleDate)
|
||||
}
|
||||
|
||||
c.oasOk(map[string]interface{}{"list": list, "total": total})
|
||||
}
|
||||
|
||||
// Stats GET /backend/oa/schedule/stats
|
||||
// 返回今日/本周/本月的日程统计与逾期数,供页面顶部统计卡片展示。
|
||||
func (c *BackendOaScheduleController) Stats() {
|
||||
claims, err := c.oaScheduleClaims()
|
||||
if err != nil {
|
||||
c.oasJsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
// 本周从周一开始
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
weekStart := now.AddDate(0, 0, -(weekday - 1)).Format("2006-01-02")
|
||||
weekEnd := now.AddDate(0, 0, 7-weekday).Format("2006-01-02")
|
||||
monthStart := now.Format("2006-01") + "-01"
|
||||
|
||||
base := func() orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.OaSchedule)).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("user_id", claims.UserID)
|
||||
}
|
||||
|
||||
todayTotal, _ := base().Filter("schedule_date", today).Count()
|
||||
todayDone, _ := base().Filter("schedule_date", today).Filter("status", 1).Count()
|
||||
weekTotal, _ := base().Filter("schedule_date__gte", weekStart).Filter("schedule_date__lte", weekEnd).Count()
|
||||
monthTotal, _ := base().Filter("schedule_date__gte", monthStart).Count()
|
||||
monthDone, _ := base().Filter("schedule_date__gte", monthStart).Filter("status", 1).Count()
|
||||
overdue, _ := base().Filter("schedule_date__lt", today).Filter("status", 0).Count()
|
||||
|
||||
c.oasOk(map[string]interface{}{
|
||||
"today_total": todayTotal,
|
||||
"today_done": todayDone,
|
||||
"week_total": weekTotal,
|
||||
"month_total": monthTotal,
|
||||
"month_done": monthDone,
|
||||
"overdue": overdue,
|
||||
})
|
||||
}
|
||||
|
||||
type oaSchedulePayload struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
ScheduleDate string `json:"schedule_date"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
AllDay int8 `json:"all_day"`
|
||||
Color string `json:"color"`
|
||||
Priority int8 `json:"priority"`
|
||||
}
|
||||
|
||||
// parseOaSchedulePayload 读取并校验新建/编辑日程的请求体;校验失败时直接输出错误响应。
|
||||
func (c *BackendOaScheduleController) parseOaSchedulePayload() (oaSchedulePayload, bool) {
|
||||
var payload oaSchedulePayload
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || json.Unmarshal(raw, &payload) != nil {
|
||||
c.oasJsonErr(400, 400, "参数错误")
|
||||
return payload, false
|
||||
}
|
||||
|
||||
payload.Title = strings.TrimSpace(payload.Title)
|
||||
if payload.Title == "" {
|
||||
c.oasJsonErr(400, 400, "请输入日程标题")
|
||||
return payload, false
|
||||
}
|
||||
if utf8.RuneCountInString(payload.Title) > 80 {
|
||||
c.oasJsonErr(400, 400, "日程标题不能超过80字")
|
||||
return payload, false
|
||||
}
|
||||
if !isValidOaDate(payload.ScheduleDate) {
|
||||
c.oasJsonErr(400, 400, "日程日期无效")
|
||||
return payload, false
|
||||
}
|
||||
if payload.AllDay != 0 {
|
||||
payload.AllDay = 1
|
||||
payload.StartTime = ""
|
||||
payload.EndTime = ""
|
||||
} else {
|
||||
if !isValidOaTime(payload.StartTime) {
|
||||
c.oasJsonErr(400, 400, "开始时间无效")
|
||||
return payload, false
|
||||
}
|
||||
if payload.EndTime != "" {
|
||||
if !isValidOaTime(payload.EndTime) {
|
||||
c.oasJsonErr(400, 400, "结束时间无效")
|
||||
return payload, false
|
||||
}
|
||||
if payload.EndTime < payload.StartTime {
|
||||
c.oasJsonErr(400, 400, "结束时间不能早于开始时间")
|
||||
return payload, false
|
||||
}
|
||||
}
|
||||
}
|
||||
if payload.Priority < 0 || payload.Priority > 2 {
|
||||
payload.Priority = 0
|
||||
}
|
||||
payload.Color = strings.TrimSpace(payload.Color)
|
||||
if payload.Color == "" {
|
||||
payload.Color = "#409EFF"
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
// Create POST /backend/oa/schedule/create
|
||||
func (c *BackendOaScheduleController) Create() {
|
||||
claims, err := c.oaScheduleClaims()
|
||||
if err != nil {
|
||||
c.oasJsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
payload, ok := c.parseOaSchedulePayload()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
item := &models.OaSchedule{
|
||||
Tid: claims.TenantId,
|
||||
UserID: uint64(claims.UserID),
|
||||
UserName: claims.Username,
|
||||
Title: payload.Title,
|
||||
Content: payload.Content,
|
||||
ScheduleDate: payload.ScheduleDate,
|
||||
StartTime: payload.StartTime,
|
||||
EndTime: payload.EndTime,
|
||||
AllDay: payload.AllDay,
|
||||
Color: payload.Color,
|
||||
Priority: payload.Priority,
|
||||
Status: 0,
|
||||
IsDeleted: 0,
|
||||
CreateTime: now,
|
||||
UpdateTime: &now,
|
||||
}
|
||||
if _, err := models.Orm.Insert(item); err != nil {
|
||||
c.oasJsonErr(500, 500, "保存失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.oasOk(item)
|
||||
}
|
||||
|
||||
// Update POST /backend/oa/schedule/update/:id
|
||||
func (c *BackendOaScheduleController) Update() {
|
||||
claims, err := c.oaScheduleClaims()
|
||||
if err != nil {
|
||||
c.oasJsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.oasJsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
payload, ok := c.parseOaSchedulePayload()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var item models.OaSchedule
|
||||
err = models.Orm.QueryTable(new(models.OaSchedule)).
|
||||
Filter("id", id).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("user_id", claims.UserID).
|
||||
One(&item)
|
||||
if err != nil {
|
||||
c.oasJsonErr(404, 404, "日程不存在")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
item.Title = payload.Title
|
||||
item.Content = payload.Content
|
||||
item.ScheduleDate = payload.ScheduleDate
|
||||
item.StartTime = payload.StartTime
|
||||
item.EndTime = payload.EndTime
|
||||
item.AllDay = payload.AllDay
|
||||
item.Color = payload.Color
|
||||
item.Priority = payload.Priority
|
||||
item.UpdateTime = &now
|
||||
|
||||
if _, err := models.Orm.Update(&item,
|
||||
"Title", "Content", "ScheduleDate", "StartTime", "EndTime",
|
||||
"AllDay", "Color", "Priority", "UpdateTime"); err != nil {
|
||||
c.oasJsonErr(500, 500, "保存失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.oasOk(item)
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/oa/schedule/delete/:id 软删除日程。
|
||||
func (c *BackendOaScheduleController) Delete() {
|
||||
claims, err := c.oaScheduleClaims()
|
||||
if err != nil {
|
||||
c.oasJsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.oasJsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
n, err := models.Orm.QueryTable(new(models.OaSchedule)).
|
||||
Filter("id", id).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("user_id", claims.UserID).
|
||||
Update(map[string]interface{}{
|
||||
"IsDeleted": int8(1),
|
||||
"DeleteTime": now,
|
||||
"UpdateTime": now,
|
||||
})
|
||||
if err != nil || n == 0 {
|
||||
c.oasJsonErr(404, 404, "日程不存在或已删除")
|
||||
return
|
||||
}
|
||||
|
||||
c.oasOk(nil)
|
||||
}
|
||||
|
||||
// FinishSchedule POST /backend/oa/schedule/finish/:id
|
||||
// 切换完成状态:待办 -> 完成(记录完成时间);完成 -> 待办(清除完成时间)。
|
||||
// 注意:方法名不能叫 Finish——Finish 是 beego Controller 的生命周期钩子,
|
||||
// 同名会导致路由调用后再被框架自动调用一次,toggle 逻辑被执行两遍(状态来回切)。
|
||||
func (c *BackendOaScheduleController) FinishSchedule() {
|
||||
claims, err := c.oaScheduleClaims()
|
||||
if err != nil {
|
||||
c.oasJsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.oasJsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
var item models.OaSchedule
|
||||
err = models.Orm.QueryTable(new(models.OaSchedule)).
|
||||
Filter("id", id).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("user_id", claims.UserID).
|
||||
One(&item)
|
||||
if err != nil {
|
||||
c.oasJsonErr(404, 404, "日程不存在")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
newStatus := int8(1)
|
||||
var finishTime *time.Time
|
||||
if item.Status == 1 {
|
||||
newStatus = 0
|
||||
} else {
|
||||
finishTime = &now
|
||||
}
|
||||
|
||||
item.Status = newStatus
|
||||
item.FinishTime = finishTime
|
||||
item.UpdateTime = &now
|
||||
|
||||
if _, err := models.Orm.Update(&item, "Status", "FinishTime", "UpdateTime"); err != nil {
|
||||
c.oasJsonErr(500, 500, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.oasOk(map[string]interface{}{"status": newStatus})
|
||||
}
|
||||
@@ -99,6 +99,8 @@ func Init(_ string) {
|
||||
new(BackendSchedule),
|
||||
new(BackendScheduleReminder),
|
||||
new(BackendScheduleReminderSendLog),
|
||||
|
||||
new(OaSchedule),
|
||||
)
|
||||
|
||||
// 创建全局 Ormer
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// OaSchedule OA日程(日历待办): yz_backend_oa_schedule
|
||||
// 日程按租户 tid + 用户 user_id 双重隔离,供 backend 日程管理页面的
|
||||
// 月历视图与待办列表读写。schedule_date/start_time/end_time 均以
|
||||
// "YYYY-MM-DD"、"HH:mm" 字符串存取,前端无需再做时区换算。
|
||||
type OaSchedule struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid int `orm:"column(tid)" json:"tid"`
|
||||
UserID uint64 `orm:"column(user_id)" json:"user_id"`
|
||||
UserName string `orm:"column(user_name);size(100);default()" json:"user_name"`
|
||||
Title string `orm:"column(title);size(255)" json:"title"`
|
||||
Content string `orm:"column(content);type(text);null" json:"content"`
|
||||
ScheduleDate string `orm:"column(schedule_date);type(date)" json:"schedule_date"`
|
||||
StartTime string `orm:"column(start_time);size(8);default()" json:"start_time"`
|
||||
EndTime string `orm:"column(end_time);size(8);default()" json:"end_time"`
|
||||
AllDay int8 `orm:"column(all_day);default(1)" json:"all_day"`
|
||||
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
|
||||
Priority int8 `orm:"column(priority);default(0)" json:"priority"`
|
||||
Status int8 `orm:"column(status);default(0)" json:"status"`
|
||||
FinishTime *time.Time `orm:"column(finish_time);type(datetime);null" json:"finish_time"`
|
||||
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *OaSchedule) TableName() string {
|
||||
return "yz_backend_oa_schedule"
|
||||
}
|
||||
@@ -227,6 +227,14 @@ func RegisterAuthRoutes() {
|
||||
beego.Router("/backend/reminder", &controllers.BackendReminderController{}, "post:CreateReminder")
|
||||
beego.Router("/backend/reminder/batchDelete", &controllers.BackendReminderController{}, "post:BatchDeleteReminder")
|
||||
beego.Router("/backend/reminder/finish/:id", &controllers.BackendReminderController{}, "post:FinishReminder")
|
||||
|
||||
// OA日程管理(日历待办)
|
||||
beego.Router("/backend/oa/schedule/list", &controllers.BackendOaScheduleController{}, "get:List")
|
||||
beego.Router("/backend/oa/schedule/stats", &controllers.BackendOaScheduleController{}, "get:Stats")
|
||||
beego.Router("/backend/oa/schedule/create", &controllers.BackendOaScheduleController{}, "post:Create")
|
||||
beego.Router("/backend/oa/schedule/update/:id", &controllers.BackendOaScheduleController{}, "post:Update")
|
||||
beego.Router("/backend/oa/schedule/delete/:id", &controllers.BackendOaScheduleController{}, "delete:Delete")
|
||||
beego.Router("/backend/oa/schedule/finish/:id", &controllers.BackendOaScheduleController{}, "post:FinishSchedule")
|
||||
}
|
||||
|
||||
// registerOrganizationRoutes 为指定模块前缀注册组织架构路由。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- OA 日程管理表(日历待办)
|
||||
-- 在租户业务库执行以下语句创建表结构。
|
||||
-- 数据按租户 tid + 用户 user_id 双重隔离,仅创建者本人可见与操作。
|
||||
|
||||
CREATE TABLE `yz_backend_oa_schedule` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`tid` int NOT NULL DEFAULT '0' COMMENT '租户ID',
|
||||
`user_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '所属用户ID',
|
||||
`user_name` varchar(100) NOT NULL DEFAULT '' COMMENT '所属用户名',
|
||||
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '日程标题',
|
||||
`content` text NULL COMMENT '日程内容/备注',
|
||||
`schedule_date` date NOT NULL COMMENT '日程日期(YYYY-MM-DD)',
|
||||
`start_time` varchar(8) NOT NULL DEFAULT '' COMMENT '开始时间 HH:mm(全天日程为空)',
|
||||
`end_time` varchar(8) NOT NULL DEFAULT '' COMMENT '结束时间 HH:mm(全天日程为空)',
|
||||
`all_day` tinyint NOT NULL DEFAULT '1' COMMENT '是否全天 0-否 1-是',
|
||||
`color` varchar(20) NOT NULL DEFAULT '#409EFF' COMMENT '标签颜色',
|
||||
`priority` tinyint NOT NULL DEFAULT '0' COMMENT '优先级 0-普通 1-重要 2-紧急',
|
||||
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态 0-待办 1-完成',
|
||||
`finish_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0-否 1-是',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid_user_date` (`tid`,`user_id`,`schedule_date`),
|
||||
KEY `idx_schedule_date` (`schedule_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA日程管理表(日历待办)';
|
||||
Reference in New Issue
Block a user