diff --git a/backend/src/api/oaSchedule.js b/backend/src/api/oaSchedule.js index c46dab0..3880d51 100644 --- a/backend/src/api/oaSchedule.js +++ b/backend/src/api/oaSchedule.js @@ -47,3 +47,21 @@ export function finishSchedule(id) { method: 'post' }) } + +// 顺延单条日程到下一天(target_date 为空时后端默认取当前日程日期 +1 天) +export function carrySchedule(id, data) { + return request({ + url: `/backend/oa/schedule/carry/${id}`, + method: 'post', + data + }) +} + +// 批量顺延指定日期的全部未完成日程 +export function carryPendingSchedules(data) { + return request({ + url: '/backend/oa/schedule/carry-pending', + method: 'post', + data + }) +} diff --git a/backend/src/views/apps/oa/dashboard/index.vue b/backend/src/views/apps/oa/dashboard/index.vue index dadc7ac..31b1462 100644 --- a/backend/src/views/apps/oa/dashboard/index.vue +++ b/backend/src/views/apps/oa/dashboard/index.vue @@ -8,33 +8,93 @@ 刷新 - - - -
-
{{ item.label }}
-
- ¥ - {{ money(item.data.total) }} -
-
- {{ item.data.count }} 单 - 已报销 ¥ {{ money(item.data.paid) }} - 未报销 ¥ {{ money(item.data.unpaid) }} -
-
-
-
+
+ +
+ + + +
+
{{ item.label }}
+
+ ¥ + {{ money(item.data.total) }} +
+
+ {{ item.data.count }} 单 + 已报销 ¥ {{ money(item.data.paid) }} + 未报销 ¥ {{ money(item.data.unpaid) }} +
+
+
+
- - - +
近6个月报销趋势
-
- + + +
+
状态分布
+
+
+ {{ + item.name + }} +
+
+
+ {{ item.count }} 单 / ¥ {{ money(item.amount) }} +
+ +
+
+ + +
+
报销类型分析
+
+
+ {{ item.name }} +
+
+
+ {{ item.count }} 笔 + ¥ {{ money(item.amount) }} +
+ +
+
+
+ + +
工作日程提醒 @@ -42,19 +102,36 @@ >查看全部
+ + + + +
- -
+
{{ item.priority === 2 ? "紧急" : "重要" }} + + 延期第 {{ item.carry_count + 1 }} 天 +
- - - - -
-
状态分布
-
-
- {{ - item.name - }} -
-
-
- {{ item.count }} 单 / ¥ {{ money(item.amount) }} -
-
- -
-
报销类型分析
-
-
- {{ item.name }} -
-
-
- {{ item.count }} 笔 - ¥ {{ money(item.amount) }} -
- -
-
+ + + + +
@@ -154,6 +206,8 @@ import * as echarts from "echarts"; import { Refresh } from "@element-plus/icons-vue"; import { getReimbursementDashboard } from "@/api/reimburse"; import { finishSchedule, getScheduleList } from "@/api/oaSchedule"; +import ScheduleDetail from "../schedule/components/detail.vue"; +import SchedulePostpone from "../schedule/components/postponeDialog.vue"; const emptyStat = () => ({ total: 0, paid: 0, unpaid: 0, count: 0 }); const dashboard = reactive({ @@ -249,6 +303,51 @@ const router = useRouter(); const scheduleReminders = ref([]); const scheduleLoading = ref(false); +// 待办分类:all-全部 today-今日 pending-未完成 done-已完成 +// 注:matchScheduleCategory 仍保留 ongoing 分支(进行中 = 跨天延期中的任务), +// 当前不启用;以后要恢复,在下面数组里加一行 { value: "ongoing", label: "进行中" } 即可。 +const scheduleTabs = [ + { value: "all", label: "全部" }, + { value: "today", label: "今日" }, + { value: "pending", label: "未完成" }, + { value: "done", label: "已完成" }, +]; +const scheduleTab = ref("all"); + +// 判断一条日程是否属于某个分类(与后端 category 语义保持一致) +const matchScheduleCategory = (item, category) => { + switch (category) { + case "today": + return item.schedule_date === fmtDate(new Date()); + case "pending": + return item.status === 0; + // 进行中 = 跨天顺延中的任务:被顺延出去的源日程,或顺延出来的续做日程 + case "ongoing": + return item.source_id > 0 || item.carry_over === 1; + case "done": + return item.status === 1; + default: + return true; + } +}; + +const filteredScheduleReminders = computed(() => + scheduleReminders.value.filter((item) => + matchScheduleCategory(item, scheduleTab.value), + ), +); + +// 各分类数量按当前面板范围(逾期 + 未来7天)统计 +const scheduleCounts = computed(() => { + const result = {}; + for (const tab of scheduleTabs) { + result[tab.value] = scheduleReminders.value.filter((item) => + matchScheduleCategory(item, tab.value), + ).length; + } + return result; +}); + const pad2 = (n) => String(n).padStart(2, "0"); const fmtDate = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; @@ -258,44 +357,29 @@ const loadScheduleReminders = async () => { scheduleLoading.value = true; try { const today = new Date(); - const todayStr = fmtDate(today); const weekLater = new Date( today.getFullYear(), today.getMonth(), today.getDate() + 7, ); - const yesterday = fmtDate( - new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1), - ); - const [overdueRes, upcomingRes] = await Promise.all([ - getScheduleList({ - end_date: yesterday, - page: 1, - pageSize: 50, - }), - getScheduleList({ - start_date: todayStr, - end_date: fmtDate(weekLater), - page: 1, - pageSize: 50, - }), - ]); - const overdue = (overdueRes?.data?.list || []).map(item => ({ - ...item, - // 统一日期格式,避免后端 parseTime 输出 Go 默认格式导致标签解析 NaN - schedule_date: String(item.schedule_date || "").slice(0, 10), - })); - const upcoming = (upcomingRes?.data?.list || []).map(item => ({ - ...item, - schedule_date: String(item.schedule_date || "").slice(0, 10), - })); - scheduleReminders.value = [...overdue, ...upcoming].sort((a, b) => { - // 未完成在前,已完成沉底,保证点击完成后条目保留可见(灰显) - if (a.status !== b.status) return a.status - b.status; - return `${a.schedule_date}${a.start_time}`.localeCompare( - `${b.schedule_date}${b.start_time}`, - ); + const res = await getScheduleList({ + end_date: fmtDate(weekLater), + page: 1, + pageSize: 500, }); + scheduleReminders.value = (res?.data?.list || []) + .map((item) => ({ + ...item, + // 统一日期格式,避免后端 parseTime 输出 Go 默认格式导致标签解析 NaN + schedule_date: String(item.schedule_date || "").slice(0, 10), + })) + .sort((a, b) => { + // 未完成在前,已完成沉底,保证点击完成后条目保留可见(灰显) + if (a.status !== b.status) return a.status - b.status; + return `${a.schedule_date}${a.start_time}`.localeCompare( + `${b.schedule_date}${b.start_time}`, + ); + }); } catch (error) { console.warn("加载日程提醒失败:", error?.message); } finally { @@ -335,16 +419,36 @@ const scheduleTimeText = (item) => const isOverdueSchedule = (item) => item.status === 0 && item.schedule_date < fmtDate(new Date()); +// 点击任务只打开详情抽屉,由用户明确选择延期 / 已完成 / 反审核,避免误点即完成 +const scheduleDetailVisible = ref(false); +const scheduleDetailItem = ref(null); +const schedulePostponeVisible = ref(false); + +const openScheduleDetail = (item) => { + scheduleDetailItem.value = item; + scheduleDetailVisible.value = true; +}; + const toggleScheduleFinish = async (item) => { const res = await finishSchedule(item.id); if (res?.code === 200) { - ElMessage.success(res.data?.status === 1 ? "已完成" : "已恢复待办"); + ElMessage.success(res.data?.status === 1 ? "已完成" : "已反审核"); + scheduleDetailVisible.value = false; loadScheduleReminders(); } else { ElMessage.error(res?.msg || "操作失败"); } }; +const handleScheduleFinish = (item) => toggleScheduleFinish(item); +const handleScheduleRevert = (item) => toggleScheduleFinish(item); + +const handleSchedulePostpone = (item) => { + scheduleDetailItem.value = item; + scheduleDetailVisible.value = false; + schedulePostponeVisible.value = true; +}; + const goSchedule = () => router.push("/apps/oa/schedule"); const loadDashboard = async () => { @@ -455,11 +559,28 @@ onBeforeUnmount(() => { } } -.chart-row { +/* 左右分栏:左 70% 报销,右 30% 日程提醒 */ +.dashboard-columns { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.col-left { + flex: 0 0 calc(70% - 8px); + min-width: 0; +} + +.col-right { + flex: 0 0 calc(30% - 8px); + min-width: 0; +} + +.col-left > .chart-card { margin-bottom: 16px; - .el-col { - margin-bottom: 8px; + &:last-child { + margin-bottom: 0; } } @@ -489,9 +610,10 @@ onBeforeUnmount(() => { } .schedule-card { - height: 100%; display: flex; flex-direction: column; + /*min-height: 340px; */ + height: 600px; .schedule-title { display: flex; @@ -504,6 +626,40 @@ onBeforeUnmount(() => { } } +.schedule-tabs { + :deep(.el-tabs__header) { + margin: 0 0 10px; + } + + /* 列表由下方 .schedule-list 承载,tabs 只作切换栏,隐藏空的面板容器 */ + :deep(.el-tabs__content) { + display: none; + } + + :deep(.el-tabs__item) { + padding: 0 10px; + font-size: 13px; + } +} + +.tab-label { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.tab-count { + min-width: 18px; + padding: 0 5px; + height: 16px; + line-height: 16px; + border-radius: 8px; + background: #f0f2f5; + color: #909399; + font-size: 11px; + text-align: center; +} + .status-card { margin-bottom: 16px; } @@ -512,7 +668,9 @@ onBeforeUnmount(() => { display: flex; flex-direction: column; gap: 8px; - min-height: 80px; + min-height: 120px; + max-height: 520px; + overflow-y: auto; } .schedule-item { @@ -523,12 +681,32 @@ onBeforeUnmount(() => { background: #fafbfc; border-left: 3px solid var(--item-color); border-radius: 4px; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: #eef2f7; + } &.done { background: #f5f7fa; border-left-color: #dcdfe6; } + /* 状态指示点:仅标识状态,点击整行弹出详情由用户选择操作 */ + .item-status-dot { + width: 10px; + height: 10px; + margin-top: 5px; + flex-shrink: 0; + border-radius: 50%; + background: var(--item-color); + + &.done { + background: #c0c4cc; + } + } + .schedule-item-main { flex: 1; min-width: 0; @@ -633,6 +811,23 @@ onBeforeUnmount(() => { white-space: nowrap; } +/* 窄屏下左右分栏改为上下堆叠 */ +@media (max-width: 1100px) { + .dashboard-columns { + flex-direction: column; + } + + .col-left, + .col-right { + flex: 1 1 auto; + width: 100%; + } + + .col-right { + margin-top: 16px; + } +} + @media (max-width: 768px) { .oa-dashboard { padding: 12px; diff --git a/backend/src/views/apps/oa/employeefile/components/fileEditDialog.vue b/backend/src/views/apps/oa/employeefile/components/fileEditDialog.vue index e490bf4..aa4ac00 100644 --- a/backend/src/views/apps/oa/employeefile/components/fileEditDialog.vue +++ b/backend/src/views/apps/oa/employeefile/components/fileEditDialog.vue @@ -53,6 +53,38 @@ + + + + + + + + + + + + + + + + @@ -149,7 +181,7 @@ + + diff --git a/backend/src/views/apps/oa/schedule/index.vue b/backend/src/views/apps/oa/schedule/index.vue index a90b4a6..dda1550 100644 --- a/backend/src/views/apps/oa/schedule/index.vue +++ b/backend/src/views/apps/oa/schedule/index.vue @@ -39,6 +39,26 @@
+ + + + + + +
@@ -51,15 +71,6 @@ 今天
- - 全部 - 待办 - 已完成 -
{{ w }}
@@ -118,15 +129,28 @@
{{ selectedDateLabel }}
-
{{ selectedItems.length }} 项日程
+
+ {{ selectedItems.length }} 项日程 +
+
+
+ 延期未完成 + 新增
- 新增
{{ priorityLabel(item.priority) }} + + 已延期至 {{ shortDate(item.carry_to_date) }} + + + 延期第 {{ item.carry_count + 1 }} 天 +
{{ timeText(item) }}
@@ -165,6 +205,11 @@ /> - + @@ -309,12 +361,15 @@ import { ArrowRight, MoreFilled, Plus, + Promotion, Refresh, Search } from "@element-plus/icons-vue"; import ScheduleEdit from "./components/edit.vue"; import ScheduleDetail from "./components/detail.vue"; +import SchedulePostpone from "./components/postponeDialog.vue"; import { + carryPendingSchedules, deleteSchedule, finishSchedule, getScheduleList, @@ -324,6 +379,19 @@ import { const viewMode = ref("calendar"); const stats = ref({}); +// ---------- 待办分类 tabs ---------- +// all-全部 today-今日 pending-未完成 done-已完成 +// 注:后端仍支持 ongoing(进行中 = 跨天延期中的任务),当前不启用; +// 以后要恢复,在下面数组里加一行 { value: "ongoing", label: "进行中" } 即可。 +const categoryTabs = [ + { value: "all", label: "全部" }, + { value: "today", label: "今日" }, + { value: "pending", label: "未完成" }, + { value: "done", label: "已完成" } +]; +const category = ref("all"); +const categoryCounts = ref({}); + // ---------- 日期工具(避免额外依赖) ---------- function pad2(n) { return String(n).padStart(2, "0"); @@ -346,12 +414,23 @@ function normDate(value) { : value || ""; } +// 在 YYYY-MM-DD 上加减天数,用于计算"顺延到下一天"的目标日期 +function addDays(dateStr, days) { + const parts = String(dateStr || "") + .slice(0, 10) + .split("-") + .map(Number); + if (parts.length !== 3 || parts.some(Number.isNaN)) { + return ""; + } + return fmtDate(new Date(parts[0], parts[1] - 1, parts[2] + days)); +} + // ---------- 日历视图 ---------- const weekNames = ["一", "二", "三", "四", "五", "六", "日"]; const now = new Date(); const calendarYear = ref(now.getFullYear()); const calendarMonth = ref(now.getMonth() + 1); -const calendarStatus = ref(""); const calendarItems = ref([]); const selectedDate = ref(todayStr()); @@ -403,6 +482,11 @@ const selectedItems = computed( () => calendarMap.value[selectedDate.value] || [] ); +// 当天仍未完成的日程数,用于"顺延未完成"批量操作 +const pendingCount = computed( + () => selectedItems.value.filter(item => item.status !== 1).length +); + const selectedDateLabel = computed(() => { const [y, m, d] = selectedDate.value.split("-").map(Number); const week = "日一二三四五六"[new Date(y, m - 1, d).getDay()]; @@ -422,9 +506,12 @@ async function loadCalendarData() { end_date: fmtDate(end), page: 1, pageSize: 500, - status: calendarStatus.value + category: category.value }); const data = res?.data || {}; + if (data.counts) { + categoryCounts.value = data.counts; + } calendarItems.value = (data.list || []).map(item => ({ ...item, schedule_date: normDate(item.schedule_date) @@ -455,7 +542,7 @@ async function loadStats() { } // ---------- 列表视图 ---------- -const listFilters = reactive({ keyword: "", status: "", range: null }); +const listFilters = reactive({ keyword: "", range: null }); const listData = ref([]); const listLoading = ref(false); const listPagination = reactive({ page: 1, pageSize: 20, total: 0 }); @@ -467,7 +554,7 @@ async function loadListData() { page: listPagination.page, pageSize: listPagination.pageSize, keyword: listFilters.keyword, - status: listFilters.status + category: category.value }; if (listFilters.range && listFilters.range.length === 2) { params.start_date = listFilters.range[0]; @@ -475,6 +562,9 @@ async function loadListData() { } const res = await getScheduleList(params); const data = res?.data || {}; + if (data.counts) { + categoryCounts.value = data.counts; + } listData.value = (data.list || []).map(item => ({ ...item, schedule_date: normDate(item.schedule_date) @@ -487,12 +577,21 @@ async function loadListData() { function resetListFilters() { listFilters.keyword = ""; - listFilters.status = ""; listFilters.range = null; listPagination.page = 1; loadListData(); } +// 切换分类:切到"今日"时日历自动回到今天所在月份,避免当月看不到数据 +function handleCategoryChange(value) { + listPagination.page = 1; + if (value === "today" && viewMode.value === "calendar") { + goToday(); + return; + } + reloadAll(); +} + // 切到列表视图时按需加载 watch(viewMode, mode => { if (mode === "list" && listData.value.length === 0) { @@ -530,23 +629,79 @@ function handleDetailEdit(item) { openEdit(item); } -// 详情内完成/取消完成后从最新数据重新定位,保持详情内容同步 -async function toggleFromDetail(item) { +// 详情里点"已完成" +async function handleDetailFinish(item) { + detailVisible.value = false; await toggleFinish(item); - detailItem.value = calendarItems.value.find(i => i.id === item.id) || null; - if (!detailItem.value) { - detailVisible.value = false; - } +} + +// 详情里点"反审核":把已完成的日程恢复为待办 +async function handleDetailRevert(item) { + detailVisible.value = false; + await toggleFinish(item); +} + +// 详情里点"延期":关闭详情后打开延期对话框 +function handleDetailPostpone(item) { + detailVisible.value = false; + openPostpone(item); } function handleItemCommand(cmd, item) { - if (cmd === "edit") { + if (cmd === "postpone") { + openPostpone(item); + } else if (cmd === "edit") { openEdit(item); } else if (cmd === "delete") { handleDelete(item); } } +// ---------- 延期到下一天 ---------- +const postponeVisible = ref(false); +const postponeItem = ref(null); + +function openPostpone(item) { + if (!item || item.status === 1) { + ElMessage.warning("该日程已完成,无需延期"); + return; + } + postponeItem.value = item; + postponeVisible.value = true; +} + +// 批量延期:把选中日期的全部未完成日程一次性延期到下一天 +async function handleCarryPending() { + if (pendingCount.value === 0) { + return; + } + // 目标日期取"选定日期+1天",但不早于今天,避免把过去的任务再延到过去 + const nextDay = addDays(selectedDate.value, 1); + const target = nextDay < todayStr() ? todayStr() : nextDay; + try { + await ElMessageBox.confirm( + `将 ${selectedDate.value} 的 ${pendingCount.value} 条未完成日程延期到 ${target}?当天这些日程会标记为已完成。`, + "批量延期", + { type: "warning", confirmButtonText: "延期", cancelButtonText: "取消" } + ); + } catch { + return; + } + const res = await carryPendingSchedules({ + date: selectedDate.value, + target_date: target + }); + if (res?.code === 200) { + const d = res.data || {}; + ElMessage.success( + `已延期 ${d.success || 0} 条${d.skipped ? `,跳过 ${d.skipped} 条` : ""}` + ); + await reloadAll(); + } else { + ElMessage.error(res?.msg || "延期失败"); + } +} + async function toggleFinish(item) { const res = await finishSchedule(item.id); if (res?.code === 200) { @@ -585,6 +740,18 @@ function reloadAll() { } // ---------- 展示辅助 ---------- +// YYYY-MM-DD -> M月D日,用于"已顺延至"标签 +function shortDate(value) { + const parts = String(value || "") + .slice(0, 10) + .split("-") + .map(Number); + if (parts.length !== 3 || parts.some(Number.isNaN)) { + return value || ""; + } + return `${parts[1]}月${parts[2]}日`; +} + function timeText(item) { if (item.all_day === 1) { return "全天"; @@ -672,6 +839,42 @@ onMounted(() => { color: #c0c4cc; } +/* 待办分类 tabs */ +.category-tabs { + background: #fff; + border: 1px solid #ebeef5; + border-radius: 8px; + padding: 0 16px; + margin-bottom: 16px; +} + +.category-tabs :deep(.el-tabs__header) { + margin: 0; +} + +/* 分类内容由下方日历/列表承载,tabs 只作切换栏,隐藏空的面板容器 */ +.category-tabs :deep(.el-tabs__content) { + display: none; +} + +.tab-label { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.tab-count { + min-width: 20px; + padding: 0 6px; + height: 18px; + line-height: 18px; + border-radius: 9px; + background: #f0f2f5; + color: #909399; + font-size: 12px; + text-align: center; +} + /* 日历视图布局 */ .calendar-layout { display: flex; @@ -864,6 +1067,13 @@ onMounted(() => { margin-top: 2px; } +.panel-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + .day-list { flex: 1; overflow-y: auto; diff --git a/backend/src/views/apps/organization/components/EmployeeEditDialog.vue b/backend/src/views/apps/organization/components/EmployeeEditDialog.vue index f6cf435..9a17656 100644 --- a/backend/src/views/apps/organization/components/EmployeeEditDialog.vue +++ b/backend/src/views/apps/organization/components/EmployeeEditDialog.vue @@ -46,66 +46,6 @@ - -
组织信息
- - - - - - - - - - - - - - - - -
联系与其它
- - - - - - - - - - - @@ -129,6 +69,56 @@ + + +
组织信息
+ + + + + + + + + + + + + +