Files
yunzerwebsiteallinone/backend/src/views/apps/oa/schedule/index.vue
T
2026-09-09 17:16:52 +08:00

1507 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">今日待办</div>
<div class="stat-value is-green">{{ todayPending }}</div>
<div class="stat-sub">
共 {{ stats.today_total || 0 }} 项 · 已完成
{{ stats.today_done || 0 }}
</div>
</div>
<div class="stat-card">
<div class="stat-label">本周日程</div>
<div class="stat-value is-blue">{{ weekPending }}</div>
<div class="stat-sub">
共 {{ stats.week_total || 0 }} 项 · 已完成
{{ stats.week_done || 0 }}
</div>
</div>
<div class="stat-card">
<div class="stat-label">本月日程</div>
<div class="stat-value is-orange">{{ monthPending }}</div>
<div class="stat-sub">
共 {{ stats.month_total || 0 }} 项 · 已完成
{{ 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 is-red">{{ stats.overdue || 0 }}</div>
<div class="stat-sub">项待处理</div>
</div>
</div>
<!-- 待办分类 tabs -->
<el-tabs
v-model="category"
class="category-tabs"
@tab-change="handleCategoryChange"
>
<el-tab-pane
v-for="tab in categoryTabs"
:key="tab.value"
:name="tab.value"
>
<template #label>
<span class="tab-label">
{{ tab.label }}
<span class="tab-count">{{ categoryCounts[tab.value] || 0 }}</span>
</span>
</template>
</el-tab-pane>
</el-tabs>
<!-- 日历视图 -->
<div v-if="viewMode === 'calendar'" class="calendar-layout">
<div class="calendar-card">
<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>
</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>
<i v-if="hasImages(item)" class="fa-solid fa-image item-image-icon"></i>
<i
v-if="item.reminder_id > 0"
class="fa-solid fa-bell item-bell"
title="已开启提醒"
></i>
<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 }} 项日程<template v-if="pendingCount > 0"
>,{{ pendingCount }} 项未完成</template
><template v-if="selectedPendingIds.length > 0"
>,已选 {{ selectedPendingIds.length }} 项</template
>
</div>
</div>
<div class="panel-actions">
<el-button
v-if="pendingCount > 0"
size="small"
:icon="Promotion"
@click="handleCarryPending"
>延期<template v-if="selectedPendingIds.length > 0"
>({{ selectedPendingIds.length }})</template
></el-button
>
<el-button
type="primary"
size="small"
:icon="Plus"
@click="openCreate(selectedDate)"
>新增</el-button
>
</div>
</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="selectedIds.includes(item.id)"
:disabled="item.status === 1"
:title="item.status === 1 ? '已完成的日程无需延期' : '勾选后批量延期'"
@click.stop
@change="checked => toggleSelect(item.id, checked)"
/>
<div class="day-item-main" @click="openDetail(item)">
<div class="day-item-title" :class="{ done: item.status === 1 }">
<i v-if="hasImages(item)" class="fa-solid fa-image item-image-icon"></i>
<i
v-if="item.reminder_id > 0"
class="fa-solid fa-bell item-bell"
title="已开启提醒"
></i>
{{ item.title }}
<el-tag
v-if="item.priority > 0"
:type="priorityTagType(item.priority)"
size="small"
effect="light"
>
{{ priorityLabel(item.priority) }}
</el-tag>
<el-tag
v-if="item.carry_over === 1"
size="small"
effect="light"
type="info"
>
已延期至 {{ shortDate(item.carry_to_date) }}
</el-tag>
<el-tag
v-else-if="item.source_id > 0"
size="small"
effect="light"
type="warning"
>
延期第 {{ item.carry_count + 1 }} 天
</el-tag>
</div>
<div class="day-item-time">{{ timeText(item) }}</div>
</div>
<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="finish">{{
item.status === 1 ? "取消完成" : "标记完成"
}}</el-dropdown-item>
<el-dropdown-item
command="postpone"
:disabled="item.status === 1"
>延期到下一天</el-dropdown-item
>
<el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item command="delete" divided
>删除</el-dropdown-item
>
</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-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 }"
/><i v-if="hasImages(row)" class="fa-solid fa-image item-image-icon"></i><i
v-if="row.reminder_id > 0"
class="fa-solid fa-bell item-image-icon"
title="已开启提醒"
></i>{{ 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="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="toggleFinish(row)">{{
row.status === 1 ? "取消完成" : "完成"
}}</el-button>
<el-button
link
type="primary"
:disabled="row.status === 1"
@click="openPostpone(row)"
>延期</el-button
>
<el-button link type="primary" @click="openEdit(row)"
>编辑</el-button
>
<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"
:draft="pendingDraft"
@saved="reloadAll"
@draft-applied="pendingDraft = null"
/>
<!-- 日程详情抽屉 -->
<ScheduleDetail
v-model="detailVisible"
:schedule="detailItem"
@edit="handleDetailEdit"
@finish="handleDetailFinish"
@revert="handleDetailRevert"
@postpone="handleDetailPostpone"
@changed="reloadAll"
/>
<!-- 延期对话框 -->
<SchedulePostpone
v-model="postponeVisible"
:schedule="postponeItem"
@saved="reloadAll"
/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
ArrowLeft,
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,
carrySchedule,
deleteSchedule,
finishSchedule,
getScheduleList,
getScheduleStats
} from "@/api/oaSchedule";
const viewMode = ref("calendar");
const stats = ref({});
// 今日剩余待办(未完成)= 今日总任务 - 今日已完成
const todayPending = computed(
() => (stats.value.today_total || 0) - (stats.value.today_done || 0)
);
// 本周 / 本月剩余未完成数量
const weekPending = computed(
() => (stats.value.week_total || 0) - (stats.value.week_done || 0)
);
const monthPending = computed(
() => (stats.value.month_total || 0) - (stats.value.month_done || 0)
);
// ---------- 待办分类 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");
}
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 || "";
}
// 在 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 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);
}
// 同一天内按紧急程度排序:紧急 > 重要 > 普通
for (const date of Object.keys(map)) {
map[date].sort(compareByPriority);
}
return map;
});
const selectedItems = computed(
() => calendarMap.value[selectedDate.value] || []
);
// 当天仍未完成的日程数,用于"顺延未完成"批量操作
const pendingCount = computed(
() => selectedItems.value.filter(item => item.status !== 1).length
);
// ---------- 多选(勾选后批量延期,区别于"完成"操作) ----------
// 只记录 id,数据刷新后自动失效
const selectedIds = ref([]);
// 已勾选且仍未完成的日程,只有这些才会被延期
const selectedPendingIds = computed(() =>
selectedIds.value.filter(id => {
const item = selectedItems.value.find(i => i.id === id);
return item && item.status !== 1;
})
);
function toggleSelect(id, checked) {
const set = new Set(selectedIds.value);
if (checked) {
set.add(id);
} else {
set.delete(id);
}
selectedIds.value = [...set];
}
function clearSelection() {
selectedIds.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,
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)
}));
// 数据刷新后旧的勾选已失效(id 可能已因延期重新生成),统一清空
clearSelection();
}
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: "", 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,
category: category.value
};
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 || {};
if (data.counts) {
categoryCounts.value = data.counts;
}
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.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) {
loadListData();
}
});
// ---------- 详情/新增/编辑/操作 ----------
const editVisible = ref(false);
const editingItem = ref(null);
const detailVisible = ref(false);
const detailItem = ref(null);
// 从通知设置页返回时恢复的草稿(含提醒设置)
const pendingDraft = 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 handleDetailFinish(item) {
detailVisible.value = false;
await toggleFinish(item);
}
// 详情里点"反审核":把已完成的日程恢复为待办
async function handleDetailRevert(item) {
detailVisible.value = false;
await toggleFinish(item);
}
// 详情里点"延期":关闭详情后打开延期对话框
function handleDetailPostpone(item) {
detailVisible.value = false;
openPostpone(item);
}
function handleItemCommand(cmd, item) {
if (cmd === "postpone") {
openPostpone(item);
} else if (cmd === "finish") {
toggleFinish(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() {
const ids = selectedPendingIds.value;
const count = ids.length || pendingCount.value;
if (count === 0) {
return;
}
// 目标日期取"选定日期+1天",但不早于今天,避免把过去的任务再延到过去
const nextDay = addDays(selectedDate.value, 1);
const target = nextDay < todayStr() ? todayStr() : nextDay;
const tip = ids.length
? `将选中的 ${count} 条日程延期到 ${target}?当天这些日程会标记为已完成。`
: `将 ${selectedDate.value} 的 ${count} 条未完成日程延期到 ${target}?当天这些日程会标记为已完成。`;
try {
await ElMessageBox.confirm(tip, "批量延期", {
type: "warning",
confirmButtonText: "延期",
cancelButtonText: "取消"
});
} catch {
return;
}
// 有勾选:逐条调用单条延期接口(后端批量接口只支持按日期整批延期)
if (ids.length) {
let success = 0;
for (const id of ids) {
const res = await carrySchedule(id, { target_date: target });
if (res?.code === 200) {
success++;
}
}
const skipped = ids.length - success;
if (success) {
ElMessage.success(
`已延期 ${success} 条${skipped ? `,跳过 ${skipped} 条` : ""}`
);
} else {
ElMessage.warning("所选日程均无法延期");
}
await reloadAll();
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) {
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);
}
// 数据刷新后用最新对象回同步 detailItem,避免备注保存 / 图片变更后
// detailItem 仍指向旧对象(其 content 未更新),导致编辑时回显旧内容
watch([calendarItems, listData], () => {
if (!detailVisible.value || !detailItem.value) {
return;
}
const id = detailItem.value.id;
const fresh =
calendarItems.value.find(i => i.id === id) ||
listData.value.find(i => i.id === id);
if (fresh) {
detailItem.value = fresh;
}
});
// ---------- 展示辅助 ----------
// 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 "全天";
}
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";
}
// 判断日程是否关联了图片(与详情页 parseImages 语义一致),用于列表标题前展示图片图标
function hasImages(item) {
const imgs = item?.images;
if (!imgs) return false;
if (Array.isArray(imgs)) return imgs.some(img => img && img.url);
if (typeof imgs === "string" && imgs.trim()) {
try {
const parsed = JSON.parse(imgs);
return Array.isArray(parsed) && parsed.some(img => img && img.url);
} catch {
return false;
}
}
return false;
}
// 排序权重:紧急(2) > 重要(1) > 普通(0)
function priorityWeight(priority) {
return { 2: 3, 1: 2 }[priority] || 1;
}
// 同天内排序:先按紧急程度降序,再按开始时间升序(全天排在最前),最后按 id
function compareByPriority(a, b) {
const diff = priorityWeight(b.priority) - priorityWeight(a.priority);
if (diff !== 0) {
return diff;
}
const ta = a.all_day === 1 ? "00:00" : a.start_time || "";
const tb = b.all_day === 1 ? "00:00" : b.start_time || "";
if (ta !== tb) {
return ta < tb ? -1 : 1;
}
return (a.id || 0) - (b.id || 0);
}
onMounted(() => {
loadStats();
loadCalendarData();
// 从通知设置页返回:恢复上次暂存的新建日程草稿
const raw = sessionStorage.getItem("oa_schedule_draft");
if (raw) {
try {
pendingDraft.value = JSON.parse(raw);
sessionStorage.removeItem("oa_schedule_draft");
editingItem.value = null;
editVisible.value = true;
ElMessage.info("已从暂存恢复未提交的新建日程");
} catch {
sessionStorage.removeItem("oa_schedule_draft");
}
}
});
</script>
<style lang="less" 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-value.is-green {
color: #67c23a;
}
.stat-value.is-blue {
color: #409eff;
}
.stat-value.is-orange {
color: #e6a23c;
}
.stat-value.is-red,
.stat-card.danger .stat-value {
color: #f56c6c;
}
.stat-sub {
font-size: 12px;
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;
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-image-icon {
flex-shrink: 0;
margin-right: 4px;
color: var(--el-text-color-secondary);
}
.item-bell {
flex-shrink: 0;
margin-right: 4px;
color: #e6a23c;
}
.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: 400px;
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;
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.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;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.page-header p {
color: var(--el-text-color-secondary);
}
.stat-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
.stat-label {
color: var(--el-text-color-secondary);
}
.stat-sub {
color: var(--el-text-color-placeholder);
}
}
.category-tabs {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.tab-count {
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.calendar-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.calendar-week-header {
border-bottom-color: var(--el-border-color-lighter);
}
.week-name {
color: var(--el-text-color-secondary);
}
.calendar-cell {
border-right-color: var(--el-border-color-lighter);
border-bottom-color: var(--el-border-color-lighter);
&:hover {
background: var(--el-fill-color-light);
}
&.other-month {
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
}
&.selected {
background: var(--el-color-primary-light-9);
box-shadow: inset 0 0 0 2px var(--el-color-primary);
}
}
.today-tag,
.month-tag {
color: var(--el-color-primary);
}
.cell-item {
background: var(--el-fill-color-light);
&:hover {
background: var(--el-fill-color);
}
}
.item-time {
color: var(--el-text-color-secondary);
}
.item-title.done {
color: var(--el-text-color-placeholder);
}
.cell-more {
color: var(--el-text-color-secondary);
&:hover {
color: var(--el-color-primary);
}
}
.day-panel {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.day-panel-header {
border-bottom-color: var(--el-border-color-lighter);
}
.day-sub {
color: var(--el-text-color-secondary);
}
.day-item {
background: var(--el-fill-color-light);
}
.day-item-title.done {
color: var(--el-text-color-placeholder);
}
.day-item-time {
color: var(--el-text-color-secondary);
}
.table-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.list-title.done {
color: var(--el-text-color-placeholder);
}
}
</style>