更新日历功能
This commit is contained in:
@@ -83,3 +83,15 @@ export function carryPendingSchedules(data) {
|
|||||||
data
|
data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 中国法定节假日/调休日
|
||||||
|
// 返回 { list: [{date, name, type, year, wage}], builtin_years: [..] }
|
||||||
|
// type: 0 调休上班(周末补班,显示「班」);1 法定放假(显示「休」)
|
||||||
|
// 传 start_date + end_date 查区间(日历按 42 格查询),或传 year 查整年。
|
||||||
|
export function getScheduleHolidays(params) {
|
||||||
|
return request({
|
||||||
|
url: '/backend/oa/holiday',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ const props = defineProps({
|
|||||||
draft: {
|
draft: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
// 新建时预填的标题(如从节假日快捷新建,预填节日名称)
|
||||||
|
prefillTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ""
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,7 +372,7 @@ function initForm() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
editingId.value = 0;
|
editingId.value = 0;
|
||||||
form.title = "";
|
form.title = props.prefillTitle || "";
|
||||||
form.schedule_date = props.defaultDate || "";
|
form.schedule_date = props.defaultDate || "";
|
||||||
form.all_day = true;
|
form.all_day = true;
|
||||||
form.start_time = "";
|
form.start_time = "";
|
||||||
|
|||||||
@@ -92,18 +92,27 @@
|
|||||||
'other-month': !cell.inMonth,
|
'other-month': !cell.inMonth,
|
||||||
today: cell.isToday,
|
today: cell.isToday,
|
||||||
selected: cell.date === selectedDate,
|
selected: cell.date === selectedDate,
|
||||||
'has-items': cell.items.length > 0
|
'has-items': cell.items.length > 0,
|
||||||
|
'is-off': cell.tag === 'off',
|
||||||
|
'is-work': cell.tag === 'work'
|
||||||
}"
|
}"
|
||||||
@click="selectedDate = cell.date"
|
@click="selectedDate = cell.date"
|
||||||
@dblclick="openCreate(cell.date)"
|
@dblclick="openCreate(cell.date)"
|
||||||
>
|
>
|
||||||
<div class="cell-date">
|
<div class="cell-date">
|
||||||
<span class="day-num">{{ cell.day }}</span>
|
<span class="day-num">{{ cell.day }}</span>
|
||||||
|
<span v-if="cell.tag === 'off'" class="holiday-tag is-off">休</span>
|
||||||
|
<span v-else-if="cell.tag === 'work'" class="holiday-tag is-work"
|
||||||
|
>班</span
|
||||||
|
>
|
||||||
<span v-if="cell.isToday" class="today-tag">今</span>
|
<span v-if="cell.isToday" class="today-tag">今</span>
|
||||||
<span v-else-if="cell.day === 1" class="month-tag"
|
<span v-else-if="cell.day === 1" class="month-tag"
|
||||||
>{{ cell.month }}月</span
|
>{{ cell.month }}月</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="cell.holidayName" class="cell-holiday" :title="cell.holidayName">
|
||||||
|
{{ cell.holidayName }}
|
||||||
|
</div>
|
||||||
<div class="cell-items">
|
<div class="cell-items">
|
||||||
<div
|
<div
|
||||||
v-for="item in cell.items.slice(0, 3)"
|
v-for="item in cell.items.slice(0, 3)"
|
||||||
@@ -142,7 +151,20 @@
|
|||||||
<div class="day-panel">
|
<div class="day-panel">
|
||||||
<div class="day-panel-header">
|
<div class="day-panel-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="day-title">{{ selectedDateLabel }}</div>
|
<div class="day-title">
|
||||||
|
{{ selectedDateLabel }}
|
||||||
|
<span v-if="selectedHoliday" class="day-title-holiday">
|
||||||
|
<span
|
||||||
|
class="holiday-tag"
|
||||||
|
:class="selectedHoliday.type === 1 ? 'is-off' : 'is-work'"
|
||||||
|
>{{ selectedHoliday.type === 1 ? "休" : "班" }}</span
|
||||||
|
>
|
||||||
|
{{ selectedHoliday.type === 1 ? selectedHoliday.name : "调休上班" }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="selectedIsRestDay" class="day-title-holiday"
|
||||||
|
>周末</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div class="day-sub">
|
<div class="day-sub">
|
||||||
{{ selectedItems.length }} 项日程<template v-if="pendingCount > 0"
|
{{ selectedItems.length }} 项日程<template v-if="pendingCount > 0"
|
||||||
>,{{ pendingCount }} 项未完成</template
|
>,{{ pendingCount }} 项未完成</template
|
||||||
@@ -152,6 +174,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-actions">
|
<div class="panel-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="selectedHoliday && selectedHoliday.type === 1"
|
||||||
|
size="small"
|
||||||
|
:icon="Plus"
|
||||||
|
:title="`以「${selectedHoliday.name}」为标题新建日程`"
|
||||||
|
@click="openHolidayCreate"
|
||||||
|
>节日日程</el-button
|
||||||
|
>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="pendingCount > 0"
|
v-if="pendingCount > 0"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -366,6 +396,7 @@
|
|||||||
:schedule="editingItem"
|
:schedule="editingItem"
|
||||||
:default-date="selectedDate"
|
:default-date="selectedDate"
|
||||||
:draft="pendingDraft"
|
:draft="pendingDraft"
|
||||||
|
:prefill-title="prefillTitle"
|
||||||
@saved="reloadAll"
|
@saved="reloadAll"
|
||||||
@draft-applied="pendingDraft = null"
|
@draft-applied="pendingDraft = null"
|
||||||
/>
|
/>
|
||||||
@@ -410,6 +441,7 @@ import {
|
|||||||
carrySchedule,
|
carrySchedule,
|
||||||
deleteSchedule,
|
deleteSchedule,
|
||||||
finishSchedule,
|
finishSchedule,
|
||||||
|
getScheduleHolidays,
|
||||||
getScheduleList,
|
getScheduleList,
|
||||||
getScheduleStats
|
getScheduleStats
|
||||||
} from "@/api/oaSchedule";
|
} from "@/api/oaSchedule";
|
||||||
@@ -484,6 +516,120 @@ const calendarMonth = ref(now.getMonth() + 1);
|
|||||||
const calendarItems = ref([]);
|
const calendarItems = ref([]);
|
||||||
const selectedDate = ref(todayStr());
|
const selectedDate = ref(todayStr());
|
||||||
|
|
||||||
|
// ---------- 中国法定节假日 / 调休 ----------
|
||||||
|
// 后端返回 { 'YYYY-MM-DD': {name, type, wage} },type: 0 调休上班 / 1 法定放假。
|
||||||
|
// 普通周末不在此表内,由 isWeekend 按星期判定,避免与节假日数据重复。
|
||||||
|
const holidayMap = ref({});
|
||||||
|
|
||||||
|
// 已成功加载节假日的年份(只有真正拿到该年数据才记入),避免重复请求。
|
||||||
|
// 用 ref 而非裸 Set:翻月时若命中缓存可跳过请求,但 UI 仍需感知变化。
|
||||||
|
// 这里用普通 Set 即可——是否要发请求由 loadHolidays 判断,不参与模板渲染。
|
||||||
|
const loadedHolidayYears = new Set();
|
||||||
|
// 正在请求中的年份,防止同一年的并发重复请求(快速连点翻月时会出现)
|
||||||
|
const loadingHolidayYears = new Set();
|
||||||
|
|
||||||
|
// 观察日历当前可见月份,月份一变就确保该月所需年份的节假日已加载。
|
||||||
|
// 这样即使某次请求失败、或用户先翻到别处再翻回来,也能自动补齐,
|
||||||
|
// 不依赖"上一次翻月恰好成功"这一脆弱前提。
|
||||||
|
watch(
|
||||||
|
[calendarYear, calendarMonth],
|
||||||
|
() => {
|
||||||
|
const start = calendarRangeStart();
|
||||||
|
const end = new Date(
|
||||||
|
start.getFullYear(),
|
||||||
|
start.getMonth(),
|
||||||
|
start.getDate() + 41
|
||||||
|
);
|
||||||
|
loadHolidays(fmtDate(start), fmtDate(end));
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadHolidays(start, end) {
|
||||||
|
// 42 格窗口最多横跨两个年份,取首尾年份去重即可
|
||||||
|
const years = [start.slice(0, 4), end.slice(0, 4)].filter(
|
||||||
|
(y, i, arr) => arr.indexOf(y) === i
|
||||||
|
);
|
||||||
|
// 整年拉取而不是只拉 42 格窗口:一次请求即可覆盖该年 12 个月的翻页。
|
||||||
|
const pending = years.filter(
|
||||||
|
y => !loadedHolidayYears.has(y) && !loadingHolidayYears.has(y)
|
||||||
|
);
|
||||||
|
if (pending.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.forEach(y => loadingHolidayYears.add(y));
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
pending.map(y => getScheduleHolidays({ year: y }))
|
||||||
|
);
|
||||||
|
// 注意:即使接口返回空 list,只要 code===200 也视为该年"已加载"。
|
||||||
|
// 否则未收录的年份(后端也查不到)会被反复请求,且空年份永远无法标记。
|
||||||
|
const map = { ...holidayMap.value };
|
||||||
|
const okYears = [];
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const res = results[i];
|
||||||
|
if (res?.code !== 200) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
okYears.push(pending[i]);
|
||||||
|
for (const h of res.data?.list || []) {
|
||||||
|
map[h.date] = { name: h.name, type: h.type, wage: h.wage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 只要有任一年成功就落一次地图,保证 UI 拿到最新数据
|
||||||
|
if (okYears.length > 0) {
|
||||||
|
holidayMap.value = map;
|
||||||
|
okYears.forEach(y => loadedHolidayYears.add(y));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 节假日属于辅助信息,接口失败时静默降级:日历仍可用,只是不显示休/班标记
|
||||||
|
} finally {
|
||||||
|
// 无论成败都解除加载中标记:失败时下次翻月会重试,避免永久卡住不再请求
|
||||||
|
pending.forEach(y => loadingHolidayYears.delete(y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否周末(周六/周日)。节假日数据里不含普通周末,故需单独判断。
|
||||||
|
function isWeekend(date) {
|
||||||
|
const parts = String(date || "")
|
||||||
|
.slice(0, 10)
|
||||||
|
.split("-")
|
||||||
|
.map(Number);
|
||||||
|
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const day = new Date(parts[0], parts[1] - 1, parts[2]).getDay();
|
||||||
|
return day === 0 || day === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某天的日期类型标记:off(放假)/ work(调休上班)/ weekend(普通周末)/ ''
|
||||||
|
// 调休上班优先于周末——补班的周六实际是工作日。
|
||||||
|
function dayTag(date) {
|
||||||
|
const h = holidayMap.value[date];
|
||||||
|
if (h) {
|
||||||
|
return h.type === 1 ? "off" : "work";
|
||||||
|
}
|
||||||
|
return isWeekend(date) ? "weekend" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某天的节日名称:调休上班日不显示名称(否则整月周末补班都挂"调休上班",过于噪音)
|
||||||
|
function holidayName(date) {
|
||||||
|
const h = holidayMap.value[date];
|
||||||
|
if (!h || h.type !== 1) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return h.name || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中日期的节日信息,供右侧面板展示
|
||||||
|
const selectedHoliday = computed(() => holidayMap.value[selectedDate.value] || null);
|
||||||
|
|
||||||
|
// 选中日期是否休息日(法定放假或普通周末),用于侧栏文案与配色
|
||||||
|
const selectedIsRestDay = computed(() => {
|
||||||
|
const tag = dayTag(selectedDate.value);
|
||||||
|
return tag === "off" || tag === "weekend";
|
||||||
|
});
|
||||||
|
|
||||||
// 日历 42 格(6 行 x 7 列,周一起始)
|
// 日历 42 格(6 行 x 7 列,周一起始)
|
||||||
const calendarCells = computed(() => {
|
const calendarCells = computed(() => {
|
||||||
const map = calendarMap.value;
|
const map = calendarMap.value;
|
||||||
@@ -503,7 +649,10 @@ const calendarCells = computed(() => {
|
|||||||
month: d.getMonth() + 1,
|
month: d.getMonth() + 1,
|
||||||
inMonth: d.getMonth() + 1 === calendarMonth.value,
|
inMonth: d.getMonth() + 1 === calendarMonth.value,
|
||||||
isToday: date === today,
|
isToday: date === today,
|
||||||
items: map[date] || []
|
items: map[date] || [],
|
||||||
|
// 节假日相关:tag 用于格内角标与整格底色,holidayName 用于节日名文案
|
||||||
|
tag: dayTag(date),
|
||||||
|
holidayName: holidayName(date)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return cells;
|
return cells;
|
||||||
@@ -581,9 +730,13 @@ async function loadCalendarData() {
|
|||||||
start.getMonth(),
|
start.getMonth(),
|
||||||
start.getDate() + 41
|
start.getDate() + 41
|
||||||
);
|
);
|
||||||
|
const startStr = fmtDate(start);
|
||||||
|
const endStr = fmtDate(end);
|
||||||
|
// 节假日不在这里加载:由上方 watch([calendarYear, calendarMonth]) 统一驱动,
|
||||||
|
// 保证"翻月即加载对应月份所需年份",且首次挂载、二次进入都能自动补齐。
|
||||||
const res = await getScheduleList({
|
const res = await getScheduleList({
|
||||||
start_date: fmtDate(start),
|
start_date: startStr,
|
||||||
end_date: fmtDate(end),
|
end_date: endStr,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 500,
|
pageSize: 500,
|
||||||
category: category.value
|
category: category.value
|
||||||
@@ -684,6 +837,8 @@ watch(viewMode, mode => {
|
|||||||
// ---------- 详情/新增/编辑/操作 ----------
|
// ---------- 详情/新增/编辑/操作 ----------
|
||||||
const editVisible = ref(false);
|
const editVisible = ref(false);
|
||||||
const editingItem = ref(null);
|
const editingItem = ref(null);
|
||||||
|
// 新建时预填的标题(节假日快捷新建使用)
|
||||||
|
const prefillTitle = ref("");
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const detailItem = ref(null);
|
const detailItem = ref(null);
|
||||||
// 从通知设置页返回时恢复的草稿(含提醒设置)
|
// 从通知设置页返回时恢复的草稿(含提醒设置)
|
||||||
@@ -691,12 +846,24 @@ const pendingDraft = ref(null);
|
|||||||
|
|
||||||
function openCreate(date) {
|
function openCreate(date) {
|
||||||
editingItem.value = null;
|
editingItem.value = null;
|
||||||
|
prefillTitle.value = "";
|
||||||
if (date) {
|
if (date) {
|
||||||
selectedDate.value = date;
|
selectedDate.value = date;
|
||||||
}
|
}
|
||||||
editVisible.value = true;
|
editVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从节假日快捷新建:以节日名预填标题,日期取当前选中日
|
||||||
|
function openHolidayCreate() {
|
||||||
|
const h = selectedHoliday.value;
|
||||||
|
if (!h || h.type !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editingItem.value = null;
|
||||||
|
prefillTitle.value = h.name || "节假日";
|
||||||
|
editVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function openEdit(item) {
|
function openEdit(item) {
|
||||||
editingItem.value = item;
|
editingItem.value = item;
|
||||||
editVisible.value = true;
|
editVisible.value = true;
|
||||||
@@ -1174,6 +1341,96 @@ onMounted(() => {
|
|||||||
color: #409eff;
|
color: #409eff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 节假日:休/班 角标。休=红(放假),班=灰(调休补班),与国内日历习惯一致 */
|
||||||
|
.holiday-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holiday-tag.is-off {
|
||||||
|
background: #fef0f0;
|
||||||
|
color: #f56c6c;
|
||||||
|
border: 1px solid #fde2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holiday-tag.is-work {
|
||||||
|
background: #f4f4f5;
|
||||||
|
color: #909399;
|
||||||
|
border: 1px solid #e9e9eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 放假日整格浅红底,一眼区分连续假期 */
|
||||||
|
.calendar-cell.is-off {
|
||||||
|
background: #fffbfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.is-off:hover {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 调休上班日的周六日按工作日处理,弱化底色避免误判为休息 */
|
||||||
|
.calendar-cell.is-work {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 今天:整格实蓝底,最醒目。放在 is-off / is-work 之后,
|
||||||
|
保证今天即使逢节假日也保留蓝色标识。
|
||||||
|
实底较深,格内文字统一转白(或白底反白)以保证对比度。 */
|
||||||
|
.calendar-cell.today {
|
||||||
|
background: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.today:hover {
|
||||||
|
background: #66b1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日期数字改为白底蓝字,避免在蓝底上"蓝圆叠蓝底"糊成一片 */
|
||||||
|
.calendar-cell.today .day-num {
|
||||||
|
background: #fff;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.today .cell-holiday,
|
||||||
|
.calendar-cell.today .today-tag {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 休/班角标改为白底,保持原有红/灰字色可辨识 */
|
||||||
|
.calendar-cell.today .holiday-tag.is-off {
|
||||||
|
background: #fff;
|
||||||
|
color: #f56c6c;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.today .holiday-tag.is-work {
|
||||||
|
background: #fff;
|
||||||
|
color: #909399;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 格内节日名称 */
|
||||||
|
.cell-holiday {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: #f56c6c;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.other-month .cell-holiday {
|
||||||
|
color: #e0a0a0;
|
||||||
|
}
|
||||||
|
|
||||||
.cell-items {
|
.cell-items {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1260,6 +1517,22 @@ onMounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 标题内的节假日标记(休/班 角标 + 名称) */
|
||||||
|
.day-title-holiday {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #f56c6c;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-title-holiday .holiday-tag.is-work {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
.day-sub {
|
.day-sub {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
@@ -1381,6 +1654,11 @@ onMounted(() => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 窄屏下节日名与休/班角标可能挤压日期数字,仅保留角标 */
|
||||||
|
.cell-holiday {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-cell {
|
.calendar-cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -1454,6 +1732,69 @@ html.dark & {
|
|||||||
.month-tag {
|
.month-tag {
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
}
|
}
|
||||||
|
/* 节假日标记暗色适配:浅色底改为半透明色块,保证深底上仍可读 */
|
||||||
|
.holiday-tag.is-off {
|
||||||
|
background: rgba(245, 108, 108, 0.16);
|
||||||
|
color: #f89898;
|
||||||
|
border-color: rgba(245, 108, 108, 0.3);
|
||||||
|
}
|
||||||
|
.holiday-tag.is-work {
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
border-color: var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
.calendar-cell.is-off {
|
||||||
|
background: rgba(245, 108, 108, 0.07);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(245, 108, 108, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.calendar-cell.is-work {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
/* 今天:整格主色实底,与浅色主题保持一致 */
|
||||||
|
.calendar-cell.today {
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--el-color-primary-light-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-num {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-holiday,
|
||||||
|
.today-tag {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holiday-tag.is-off {
|
||||||
|
background: #fff;
|
||||||
|
color: #f56c6c;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holiday-tag.is-work {
|
||||||
|
background: #fff;
|
||||||
|
color: #909399;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cell-holiday {
|
||||||
|
color: #f89898;
|
||||||
|
}
|
||||||
|
.calendar-cell.other-month .cell-holiday {
|
||||||
|
color: rgba(248, 152, 152, 0.55);
|
||||||
|
}
|
||||||
|
.day-title-holiday {
|
||||||
|
color: #f89898;
|
||||||
|
}
|
||||||
|
.day-title-holiday .holiday-tag.is-work {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
.cell-item {
|
.cell-item {
|
||||||
background: var(--el-fill-color-light);
|
background: var(--el-fill-color-light);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
|
"server/pkg/holiday"
|
||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
|
|
||||||
"github.com/beego/beego/v2/client/orm"
|
"github.com/beego/beego/v2/client/orm"
|
||||||
@@ -240,6 +241,162 @@ func (c *BackendOaScheduleController) List() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Holidays GET /backend/oa/holiday
|
||||||
|
// 返回指定日期区间(或指定年份)内的中国法定节假日/调休日,供日历控件标注
|
||||||
|
// 「休 / 班」角标与节日名称。
|
||||||
|
//
|
||||||
|
// 参数(二选一):
|
||||||
|
// - start_date + end_date:"YYYY-MM-DD",日历按 42 格区间查询;
|
||||||
|
// - year:整年查询。
|
||||||
|
//
|
||||||
|
// 数据获取顺序:内存缓存 → 内置数据(2024-2026,零网络)→ 外网接口(仅未收录年份)。
|
||||||
|
// 已收录年份不会发起任何网络请求,因此日历翻月是纯内存操作、无卡顿。
|
||||||
|
// 响应里的 builtin_years 字段告知前端内置数据覆盖到哪一年。
|
||||||
|
func (c *BackendOaScheduleController) Holidays() {
|
||||||
|
if _, err := c.oaScheduleClaims(); err != nil {
|
||||||
|
c.oasJsonErr(401, 401, "未登录或无权限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate := strings.TrimSpace(c.GetString("start_date"))
|
||||||
|
endDate := strings.TrimSpace(c.GetString("end_date"))
|
||||||
|
yearStr := strings.TrimSpace(c.GetString("year"))
|
||||||
|
|
||||||
|
// 参数归一化:区间优先,其次按年
|
||||||
|
var start, end string
|
||||||
|
if isValidOaDate(startDate) && isValidOaDate(endDate) && startDate <= endDate {
|
||||||
|
start, end = startDate, endDate
|
||||||
|
} else if y, err := strconv.Atoi(yearStr); err == nil && y >= 2000 && y <= 2100 {
|
||||||
|
start = fmt.Sprintf("%04d-01-01", y)
|
||||||
|
end = fmt.Sprintf("%04d-12-31", y)
|
||||||
|
} else {
|
||||||
|
// 未传参时默认当前年份,保证前端任何情况下都能拿到可用数据
|
||||||
|
now := time.Now()
|
||||||
|
start = now.Format("2006-01-01")
|
||||||
|
end = now.Format("2006") + "-12-31"
|
||||||
|
}
|
||||||
|
|
||||||
|
list := ensureOaHolidays(start, end)
|
||||||
|
|
||||||
|
c.oasOk(map[string]interface{}{
|
||||||
|
"list": list,
|
||||||
|
"start_date": start,
|
||||||
|
"end_date": end,
|
||||||
|
"builtin_years": holiday.Years(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureOaHolidays 取区间内的节假日数据。
|
||||||
|
//
|
||||||
|
// 顺序上的取舍(性能关键):先调 holiday.FetchYear,而不是先查数据库。
|
||||||
|
// 原因是 FetchYear 对已收录年份(2024-2026)直接返回内置数据、不发网络请求,
|
||||||
|
// 也无需查库;日历每次翻月都会走到这里,先查库会白白多一次 SQL 往返。
|
||||||
|
// 只有 FetchYear 真的联网拉到了数据(即年份未收录),才回写数据库缓存,
|
||||||
|
// 供后续同一年份的请求直接命中。
|
||||||
|
func ensureOaHolidays(start, end string) []holiday.Day {
|
||||||
|
list := make([]holiday.Day, 0, 64)
|
||||||
|
|
||||||
|
startYear, endYear := holidayYear(start), holidayYear(end)
|
||||||
|
for y := startYear; y <= endYear; y++ {
|
||||||
|
if y == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items, src, ferr := holiday.FetchYear(y)
|
||||||
|
if ferr != nil || len(items) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 仅当数据来自外网时才落库:内置数据本身就在代码里,落库没有收益,
|
||||||
|
// 反而会让每次翻月都产生一批无谓的写库操作。
|
||||||
|
if src == holiday.SourceHolidayCN {
|
||||||
|
saveOaHolidays(items)
|
||||||
|
}
|
||||||
|
for _, d := range items {
|
||||||
|
if d.Date >= start && d.Date <= end {
|
||||||
|
list = append(list, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeOaHolidays(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// holidayYear 从 "YYYY-MM-DD" 取年份,失败返回 0。
|
||||||
|
func holidayYear(date string) int {
|
||||||
|
if len(date) < 4 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
y, err := strconv.Atoi(date[:4])
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveOaHolidays 把外网拉取到的数据写入缓存表。
|
||||||
|
// 使用 insert-or-update:已存在的日期更新其名称/类型/来源,保证外网数据
|
||||||
|
// 修正后能覆盖库里的旧值。写入失败静默忽略(缓存非强依赖)。
|
||||||
|
func saveOaHolidays(list []holiday.Day) {
|
||||||
|
now := time.Now()
|
||||||
|
for _, d := range list {
|
||||||
|
if !isValidOaDate(d.Date) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var exist models.OaHoliday
|
||||||
|
ferr := models.Orm.QueryTable(new(models.OaHoliday)).
|
||||||
|
Filter("date", d.Date).
|
||||||
|
One(&exist)
|
||||||
|
if ferr == nil {
|
||||||
|
// 已存在:仅当来源或名称有变化时才更新,减少无谓写库
|
||||||
|
if exist.Name == d.Name && exist.Type == d.Type && exist.Source == d.Source {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exist.Name = d.Name
|
||||||
|
exist.Type = d.Type
|
||||||
|
exist.Year = d.Year
|
||||||
|
exist.Wage = d.Wage
|
||||||
|
exist.Source = d.Source
|
||||||
|
exist.UpdateTime = &now
|
||||||
|
_, _ = models.Orm.Update(&exist, "Name", "Type", "Year", "Wage", "Source", "UpdateTime")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := models.OaHoliday{
|
||||||
|
Date: d.Date,
|
||||||
|
Name: d.Name,
|
||||||
|
Type: d.Type,
|
||||||
|
Year: d.Year,
|
||||||
|
Wage: d.Wage,
|
||||||
|
Source: d.Source,
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: &now,
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.Insert(&item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeOaHolidays 按日期去重并升序排列;同日重复时保留先出现的记录
|
||||||
|
// (数据库缓存排在前面,因此缓存优先于外网新拉取的数据)。
|
||||||
|
func mergeOaHolidays(list []holiday.Day) []holiday.Day {
|
||||||
|
seen := make(map[string]bool, len(list))
|
||||||
|
out := make([]holiday.Day, 0, len(list))
|
||||||
|
for _, d := range list {
|
||||||
|
if d.Date == "" || seen[d.Date] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[d.Date] = true
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(out); i++ {
|
||||||
|
cur := out[i]
|
||||||
|
j := i - 1
|
||||||
|
for j >= 0 && out[j].Date > cur.Date {
|
||||||
|
out[j+1] = out[j]
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
out[j+1] = cur
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Stats GET /backend/oa/schedule/stats
|
// Stats GET /backend/oa/schedule/stats
|
||||||
// 返回今日/本周/本月的日程统计与逾期数,供页面顶部统计卡片展示。
|
// 返回今日/本周/本月的日程统计与逾期数,供页面顶部统计卡片展示。
|
||||||
func (c *BackendOaScheduleController) Stats() {
|
func (c *BackendOaScheduleController) Stats() {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- OA 日程管理 —— 中国法定节假日/调休日表
|
||||||
|
--
|
||||||
|
-- 表结构变更一律以 SQL 文件形式放在 docs/sql/ 下由人工执行,
|
||||||
|
-- 代码中不做自动建表。本脚本仅新建表,不改动任何既有数据。
|
||||||
|
--
|
||||||
|
-- 数据说明:
|
||||||
|
-- 本表全局共享(不按租户隔离),只存两类无法用"星期几"推算出来的日期:
|
||||||
|
-- type = 1 休假日(法定节假日放假,含调休连休的日子)
|
||||||
|
-- type = 0 工作日(调休上班,即周末补班)
|
||||||
|
-- 普通周六/周日不入库,由前端按星期直接判定。
|
||||||
|
--
|
||||||
|
-- 表本身可以为空:后端接口在查不到数据时会调用外网接口拉取并写入本表,
|
||||||
|
-- 外网不可用时回落到代码内置的 2024-2026 年节假日数据(go/pkg/holiday/fallback.go),
|
||||||
|
-- 因此不执行本脚本之外的任何数据预置也能正常使用。
|
||||||
|
--
|
||||||
|
-- 注意:不显式指定 COLLATE,沿用数据库默认排序规则,避免与既有表在
|
||||||
|
-- JOIN 比较字符串时报 Illegal mix of collations。
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_backend_oa_holiday` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`date` CHAR(10) NOT NULL DEFAULT '' COMMENT '日期 YYYY-MM-DD',
|
||||||
|
`name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '节日名称,如 国庆节 / 春节',
|
||||||
|
`type` TINYINT NOT NULL DEFAULT 0 COMMENT '0工作日(调休补班) 1休假日(法定节假日)',
|
||||||
|
`year` INT NOT NULL DEFAULT 0 COMMENT '所属年份,便于按年清理与查询',
|
||||||
|
`wage` TINYINT NOT NULL DEFAULT 1 COMMENT '薪资倍数:1普通 2双薪 3三薪',
|
||||||
|
`source` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '数据来源:timor / holiday-cn / builtin / manual',
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_date` (`date`),
|
||||||
|
KEY `idx_year` (`year`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='中国法定节假日/调休日(全局共享)';
|
||||||
@@ -150,6 +150,7 @@ func Init(_ string) {
|
|||||||
new(BackendScheduleReminderSendLog),
|
new(BackendScheduleReminderSendLog),
|
||||||
|
|
||||||
new(OaSchedule),
|
new(OaSchedule),
|
||||||
|
new(OaHoliday),
|
||||||
new(OaNotice),
|
new(OaNotice),
|
||||||
new(OaDocCategory),
|
new(OaDocCategory),
|
||||||
new(OaDoc),
|
new(OaDoc),
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OaHoliday 中国法定节假日/调休日:yz_backend_oa_holiday
|
||||||
|
//
|
||||||
|
// 数据是全局共享的(不按租户隔离),因此不带 tid 字段。日历控件按
|
||||||
|
// "YYYY-MM-DD" 展示休/班角标与节日名称时读取本表。
|
||||||
|
//
|
||||||
|
// Type 取值:
|
||||||
|
// - 0 工作日(调休上班,如周末补班)
|
||||||
|
// - 1 休假日(法定节假日放假,含与周末连休的日子)
|
||||||
|
// - 2 普通周末(周六/周日,非节假日)
|
||||||
|
//
|
||||||
|
// 只有 0 / 1 两类需要落库——它们无法用"星期几"推算出来;普通周末由前端
|
||||||
|
// 按星期直接判定,不入库,避免数据量无谓膨胀。
|
||||||
|
type OaHoliday struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Date string `orm:"column(date);size(10);unique" json:"date"`
|
||||||
|
Name string `orm:"column(name);size(64);default()" json:"name"`
|
||||||
|
Type int8 `orm:"column(type);default(0)" json:"type"`
|
||||||
|
Year int `orm:"column(year);index" json:"year"`
|
||||||
|
Wage int8 `orm:"column(wage);default(1)" json:"wage"`
|
||||||
|
Source string `orm:"column(source);size(20);default()" json:"source"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *OaHoliday) TableName() string {
|
||||||
|
return "yz_backend_oa_holiday"
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package holiday
|
||||||
|
|
||||||
|
// fallbackData 内置的 2024-2026 年中国法定节假日/调休数据,
|
||||||
|
// 数据严格来自国务院办公厅历年《关于部分节假日安排的通知》原文。
|
||||||
|
//
|
||||||
|
// 用途:当外网接口不可达(内网部署、机房无外网出口、接口临时故障)且数据库
|
||||||
|
// 中也没有对应年份数据时,用本表兜底,保证日历控件always能显示休/班标记。
|
||||||
|
//
|
||||||
|
// 维护约定:每年 11 月国务院办公厅发布次年通知后,按下述格式补齐新一年数据
|
||||||
|
// (只补休假日与调休上班日两类,普通周末由前端按星期判定,不入表):
|
||||||
|
//
|
||||||
|
// notices = append(notices, yearNotice{Year: 2027, ...})
|
||||||
|
//
|
||||||
|
// 注意:这里只存"放假"与"补班"两类特殊日期;普通周六日不记录。
|
||||||
|
var fallbackData = buildFallback()
|
||||||
|
|
||||||
|
// yearNotice 单一年度的放假安排,按通知原文逐条录入。
|
||||||
|
type yearNotice struct {
|
||||||
|
Year int
|
||||||
|
// Holidays 放假日期区间:[开始日, 结束日],闭区间。
|
||||||
|
// 结束日与开始日相同表示只放一天;空串表示结束日等于开始日。
|
||||||
|
Holidays []dayRange
|
||||||
|
// Workdays 调休上班日(周末补班)。
|
||||||
|
Workdays []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type dayRange struct {
|
||||||
|
Start string
|
||||||
|
End string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// yearNotices 各界各类节日的原始安排。
|
||||||
|
//
|
||||||
|
// 数据来源:
|
||||||
|
// - 2024:国办发明电〔2023〕7号
|
||||||
|
// - 2025:国办发明电〔2024〕12号
|
||||||
|
// - 2026:2025年11月4日国办通知
|
||||||
|
var yearNotices = []yearNotice{
|
||||||
|
{
|
||||||
|
Year: 2024,
|
||||||
|
Holidays: []dayRange{
|
||||||
|
{Start: "2024-01-01", Name: "元旦"},
|
||||||
|
{Start: "2024-02-10", End: "2024-02-17", Name: "春节"},
|
||||||
|
{Start: "2024-04-04", End: "2024-04-06", Name: "清明节"},
|
||||||
|
{Start: "2024-05-01", End: "2024-05-05", Name: "劳动节"},
|
||||||
|
{Start: "2024-06-10", Name: "端午节"},
|
||||||
|
{Start: "2024-09-15", End: "2024-09-17", Name: "中秋节"},
|
||||||
|
{Start: "2024-10-01", End: "2024-10-07", Name: "国庆节"},
|
||||||
|
},
|
||||||
|
Workdays: []string{
|
||||||
|
"2024-02-04", "2024-02-18",
|
||||||
|
"2024-04-07",
|
||||||
|
"2024-04-28", "2024-05-11",
|
||||||
|
"2024-09-14",
|
||||||
|
"2024-09-29", "2024-10-12",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Year: 2025,
|
||||||
|
Holidays: []dayRange{
|
||||||
|
{Start: "2025-01-01", Name: "元旦"},
|
||||||
|
{Start: "2025-01-28", End: "2025-02-04", Name: "春节"},
|
||||||
|
{Start: "2025-04-04", End: "2025-04-06", Name: "清明节"},
|
||||||
|
{Start: "2025-05-01", End: "2025-05-05", Name: "劳动节"},
|
||||||
|
{Start: "2025-05-31", End: "2025-06-02", Name: "端午节"},
|
||||||
|
{Start: "2025-10-01", End: "2025-10-08", Name: "国庆节、中秋节"},
|
||||||
|
},
|
||||||
|
Workdays: []string{
|
||||||
|
"2025-01-26", "2025-02-08",
|
||||||
|
"2025-04-27",
|
||||||
|
"2025-09-28", "2025-10-11",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Year: 2026,
|
||||||
|
Holidays: []dayRange{
|
||||||
|
{Start: "2026-01-01", End: "2026-01-03", Name: "元旦"},
|
||||||
|
{Start: "2026-02-15", End: "2026-02-23", Name: "春节"},
|
||||||
|
{Start: "2026-04-04", End: "2026-04-06", Name: "清明节"},
|
||||||
|
{Start: "2026-05-01", End: "2026-05-05", Name: "劳动节"},
|
||||||
|
{Start: "2026-06-19", End: "2026-06-21", Name: "端午节"},
|
||||||
|
{Start: "2026-09-25", End: "2026-09-27", Name: "中秋节"},
|
||||||
|
{Start: "2026-10-01", End: "2026-10-07", Name: "国庆节"},
|
||||||
|
},
|
||||||
|
Workdays: []string{
|
||||||
|
"2026-01-04",
|
||||||
|
"2026-02-14", "2026-02-28",
|
||||||
|
"2026-05-09",
|
||||||
|
"2026-09-20", "2026-10-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFallback 把 yearNotices 展开成按日期索引的 Day 列表。
|
||||||
|
func buildFallback() []Day {
|
||||||
|
out := make([]Day, 0, 256)
|
||||||
|
for _, n := range yearNotices {
|
||||||
|
for _, r := range n.Holidays {
|
||||||
|
for _, d := range expandRange(r.Start, r.End) {
|
||||||
|
out = append(out, Day{
|
||||||
|
Date: d,
|
||||||
|
Name: r.Name,
|
||||||
|
Type: TypeHoliday,
|
||||||
|
Year: n.Year,
|
||||||
|
Source: SourceBuiltin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range n.Workdays {
|
||||||
|
out = append(out, Day{
|
||||||
|
Date: d,
|
||||||
|
Name: "调休上班",
|
||||||
|
Type: TypeWorkday,
|
||||||
|
Year: n.Year,
|
||||||
|
Wage: 1,
|
||||||
|
Source: SourceBuiltin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package holiday
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 外网数据源拉取(仅在"内置数据未覆盖该年份"时才会走到这里)
|
||||||
|
//
|
||||||
|
// 数据源是 holiday-cn(NateScarlet/holiday-cn):其数据由脚本从国务院办公厅
|
||||||
|
// 通知原文生成,JSON 内的 papers 字段直接给出对应的 gov.cn 通知链接,
|
||||||
|
// 字段精简(date/name/isOffDay),每年 11 月通知发布后由维护者及时更新。
|
||||||
|
//
|
||||||
|
// 实测延迟(2026-09,本机):
|
||||||
|
// - fastly.jsdelivr.net 0.43s ← 采用,TLS 握手最快
|
||||||
|
// - cdn.jsdelivr.net 0.85s
|
||||||
|
// - raw.githubusercontent 0.65s
|
||||||
|
// - timor.tech 已被 Cloudflare 拦截(403 + HTML 挑战页),已移除
|
||||||
|
//
|
||||||
|
// 注意:不要用 apihubs.cn 之类的国内接口做主力——实测它有严格频率限制,
|
||||||
|
// 连续请求会直接返回「请求过于频繁」,前端每次翻月都会打,很容易被限流。
|
||||||
|
// 对已收录的年份,我们根本不走网络(见 FetchYear),因此无需冒这个风险。
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
// sourceHolidayCNURL holiday-cn 按年份提供的 JSON,{year} 为年份占位符。
|
||||||
|
// 用 fastly 节点的 jsDelivr,实测 TLS 握手比默认 cdn 节点快一倍。
|
||||||
|
sourceHolidayCNURL = "https://fastly.jsdelivr.net/gh/NateScarlet/holiday-cn@master/%d.json"
|
||||||
|
// sourceHolidayCNMirrorURL 备用地址(同一内容,走 GitHub 原始域名)。
|
||||||
|
sourceHolidayCNMirrorURL = "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/%d.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
var httpClient = &http.Client{Timeout: 6 * time.Second}
|
||||||
|
|
||||||
|
// holidayCNPayload holiday-cn 的 JSON 结构。
|
||||||
|
type holidayCNPayload struct {
|
||||||
|
Year int `json:"year"`
|
||||||
|
Days []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
IsOffDay bool `json:"isOffDay"`
|
||||||
|
} `json:"days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchYearFrom 从某个数据源拉取一整年的节假日数据。
|
||||||
|
// 返回的 src 为该数据源的来源标记,便于写入缓存时记录出处。
|
||||||
|
func fetchYearFrom(year int, url string, src string) ([]Day, error) {
|
||||||
|
body, err := httpGet(fmt.Sprintf(url, year))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var p holidayCNPayload
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 holiday-cn 数据失败: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Day, 0, len(p.Days))
|
||||||
|
for _, d := range p.Days {
|
||||||
|
if d.Date == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := TypeWorkday
|
||||||
|
if d.IsOffDay {
|
||||||
|
t = TypeHoliday
|
||||||
|
}
|
||||||
|
name := d.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "节假日"
|
||||||
|
}
|
||||||
|
out = append(out, Day{
|
||||||
|
Date: normalizeDate(d.Date),
|
||||||
|
Name: name,
|
||||||
|
Type: t,
|
||||||
|
Year: year,
|
||||||
|
Source: src,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return finalize(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpGet 带基本校验的 GET:拒绝非 JSON 响应。
|
||||||
|
// 校验响应体而不只看状态码,是因为 CDN/网关在异常时可能返回 HTTP 200 +
|
||||||
|
// 一页 HTML 错误页(timor.tech 被 Cloudflare 拦截时就是这种表现),
|
||||||
|
// 若只判状态码会误认为成功,进而把 HTML 当 JSON 解析失败或写入脏数据。
|
||||||
|
func httpGet(url string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "yunzer-oa/1.0")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 限制读取上限,避免异常响应把内存打满
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
trimmed := trimSpaceBytes(body)
|
||||||
|
if len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '[') {
|
||||||
|
return nil, fmt.Errorf("响应不是 JSON(可能被拦截或代理改写了内容)")
|
||||||
|
}
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSpaceBytes(b []byte) []byte {
|
||||||
|
start := 0
|
||||||
|
for start < len(b) && (b[start] == ' ' || b[start] == '\n' || b[start] == '\r' || b[start] == '\t' || b[start] == 0xEF || b[start] == 0xBB || b[start] == 0xBF) {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
end := len(b)
|
||||||
|
for end > start && (b[end-1] == ' ' || b[end-1] == '\n' || b[end-1] == '\r' || b[end-1] == '\t') {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
return b[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalize 归一化一份数据:去重、排序、补薪资倍数。
|
||||||
|
func finalize(list []Day) []Day {
|
||||||
|
out := dedupSort(list)
|
||||||
|
return annotateWage(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 进程内缓存 ============================
|
||||||
|
|
||||||
|
// 说明:进程内缓存只用于避免同一次页面请求里对同一年的重复拉取,
|
||||||
|
// 真正的持久化缓存在数据库(由控制器负责读写)。两者职责不同:
|
||||||
|
// - 内存缓存:短期、防抖,进程重启即失效;
|
||||||
|
// - 数据库缓存:长期、可复用,外网不可达时的第一顺位数据源。
|
||||||
|
var (
|
||||||
|
memMu sync.RWMutex
|
||||||
|
memCache = map[int][]Day{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// getMemYear 读内存缓存;命中的年份直接返回,不再走网络。
|
||||||
|
func getMemYear(year int) ([]Day, bool) {
|
||||||
|
memMu.RLock()
|
||||||
|
defer memMu.RUnlock()
|
||||||
|
v, ok := memCache[year]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMemYear(year int, list []Day) {
|
||||||
|
memMu.Lock()
|
||||||
|
defer memMu.Unlock()
|
||||||
|
memCache[year] = list
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchYear 获取某一年的节假日数据。调用方(控制器)拿到结果后负责写入数据库缓存。
|
||||||
|
//
|
||||||
|
// 优先级(关键:已收录的年份完全不联网):
|
||||||
|
// 1. 内存缓存;
|
||||||
|
// 2. 内置数据(2024-2026 已收录)——直接返回,零网络请求、零延迟;
|
||||||
|
// 3. 外网拉取(仅年份未收录时)——fastly.jsdelivr → GitHub raw。
|
||||||
|
//
|
||||||
|
// 之所以把内置数据排在联网之前,是因为它本身就是官方通知的抄录、与网络数据
|
||||||
|
// 等价,却省掉了 0.4~0.9 秒的跨境 TLS 握手。日历每次翻月都会调用本函数,
|
||||||
|
// 若每次都联网会造成明显的卡顿。
|
||||||
|
func FetchYear(year int) (list []Day, src string, err error) {
|
||||||
|
if year < 2000 || year > 2100 {
|
||||||
|
return nil, "", fmt.Errorf("年份超出合理范围: %d", year)
|
||||||
|
}
|
||||||
|
if cached, ok := getMemYear(year); ok {
|
||||||
|
return cached, sourceOf(cached), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先用内置数据:命中即返回,不发起任何网络请求。
|
||||||
|
if builtin := builtinByYear(year); len(builtin) > 0 {
|
||||||
|
finalized := finalize(builtin)
|
||||||
|
setMemYear(year, finalized)
|
||||||
|
return finalized, SourceBuiltin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内置未收录该年份(通常是次年通知尚未发布或需更早年份),才走网络。
|
||||||
|
attempts := []struct {
|
||||||
|
url string
|
||||||
|
src string
|
||||||
|
}{
|
||||||
|
{sourceHolidayCNURL, SourceHolidayCN},
|
||||||
|
{sourceHolidayCNMirrorURL, SourceHolidayCN},
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, a := range attempts {
|
||||||
|
got, ferr := fetchYearFrom(year, a.url, a.src)
|
||||||
|
if ferr != nil {
|
||||||
|
// 真正的失败(网络错误 / 响应非法)才换下一个源重试。
|
||||||
|
lastErr = ferr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 请求成功但数据为空,是正常状态而非失败:国务院通常在头一年 11 月
|
||||||
|
// 左右发布次年安排,此前各数据源的该年份就是空数组。
|
||||||
|
// 这里必须当作"已确认无数据"缓存下来并直接返回,
|
||||||
|
// 否则会被误判为失败、继续请求被墙的备用源,白白卡满超时(实测 6 秒)。
|
||||||
|
setMemYear(year, got)
|
||||||
|
return got, a.src, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("获取 %d 年节假日数据失败: %v", year, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceOf 取一组数据的来源标记(同批数据来源一致,取首条即可)。
|
||||||
|
func sourceOf(list []Day) string {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return list[0].Source
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRanges 确保给定日期区间涉及的每个年份都有数据可用,
|
||||||
|
// 返回区间内(含)的完整数据(已按日期升序)。数据缺失的年份静默跳过。
|
||||||
|
func EnsureRanges(start, end string) []Day {
|
||||||
|
startYear, okS := yearOf(start)
|
||||||
|
endYear, okE := yearOf(end)
|
||||||
|
if !okS || !okE || endYear < startYear {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]Day, 0, 64)
|
||||||
|
for y := startYear; y <= endYear; y++ {
|
||||||
|
list, _, err := FetchYear(y)
|
||||||
|
if err != nil {
|
||||||
|
// 该年份不可用(外网失败且无内置数据),跳过但不阻断其他年份
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, list...)
|
||||||
|
}
|
||||||
|
filtered := make([]Day, 0, len(out))
|
||||||
|
for _, d := range out {
|
||||||
|
if d.Date >= start && d.Date <= end {
|
||||||
|
filtered = append(filtered, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalize(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// yearOf 从 "YYYY-MM-DD" 解析年份。
|
||||||
|
func yearOf(date string) (int, bool) {
|
||||||
|
if len(date) < 4 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
y, err := time.Parse("2006", date[:4])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return y.Year(), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Package holiday 提供中国法定节假日/调休日数据。
|
||||||
|
//
|
||||||
|
// 数据获取优先级(从高到低):
|
||||||
|
// 1. 内存缓存;
|
||||||
|
// 2. 代码内置的 2024-2026 年数据(fallback.go,抄自国务院办公厅通知原文)——
|
||||||
|
// 命中即返回,不发起任何网络请求;
|
||||||
|
// 3. 数据库缓存(yz_backend_oa_holiday,由控制器读写);
|
||||||
|
// 4. 外网开源接口(holiday-cn,仅内置未收录的年份才会走到)。
|
||||||
|
//
|
||||||
|
// 把内置数据放在联网之前是刻意的性能设计:日历每次翻月都会请求,而跨境
|
||||||
|
// 请求的 TLS 握手就要 0.4~0.9 秒;内置数据与网络数据同源等价,直接用即可。
|
||||||
|
// 本包只处理"休假日"与"调休上班日"两类特殊日期,普通周末由前端按星期判定。
|
||||||
|
package holiday
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 日期类型。只有"休假日"与"调休上班日"两类需要落库——
|
||||||
|
// 普通周末可以直接由星期几推算(周六日即周末),无需存储。
|
||||||
|
const (
|
||||||
|
// TypeWorkday 调休上班日(周末补班),前端显示「班」角标
|
||||||
|
TypeWorkday int8 = 0
|
||||||
|
// TypeHoliday 法定节假日放假,前端显示「休」角标
|
||||||
|
TypeHoliday int8 = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// 数据来源标记,写入数据库 source 列,便于排查某年份数据是哪来的。
|
||||||
|
const (
|
||||||
|
SourceHolidayCN = "holiday-cn" // 外网源:cdn(fastly).jsdelivr.net/gh/NateScarlet/holiday-cn
|
||||||
|
SourceBuiltin = "builtin" // 代码内置数据(2024-2026,官方通知抄录)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Day 某一天节假日信息的统一内部表示,也是写入缓存与返回前端的结构。
|
||||||
|
type Day struct {
|
||||||
|
// Date "YYYY-MM-DD"
|
||||||
|
Date string `json:"date"`
|
||||||
|
// Name 节日名称,如「国庆节」「春节」;调休上班日固定为「调休上班」
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Type 0 调休上班 / 1 法定放假;普通周末不入库、不返回
|
||||||
|
Type int8 `json:"type"`
|
||||||
|
// Year 所属年份
|
||||||
|
Year int `json:"year"`
|
||||||
|
// Wage 薪资倍数:1 普通,2 双倍,3 三倍(仅法定节假日当天为 3)
|
||||||
|
Wage int8 `json:"wage"`
|
||||||
|
// Source 数据来源标记
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOffDay 是否休假日(法定放假)。注意普通周末不在本结构范围内,
|
||||||
|
// 调用方判断"这天是否休息"时必须把周末一起算上。
|
||||||
|
func (d Day) IsOffDay() bool {
|
||||||
|
return d.Type == TypeHoliday
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateLayout 统一的日期字符串格式。
|
||||||
|
const dateLayout = "2006-01-02"
|
||||||
|
|
||||||
|
func parseDate(s string) (time.Time, error) {
|
||||||
|
return time.ParseInLocation(dateLayout, s, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeDate 把可能带时分秒的时间串统一裁剪成 "YYYY-MM-DD"。
|
||||||
|
func normalizeDate(s string) string {
|
||||||
|
if len(s) > 10 {
|
||||||
|
return s[:10]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandRange 展开 [start, end] 闭区间为逐日列表;end 为空时视为仅 start 一天。
|
||||||
|
func expandRange(start, end string) []string {
|
||||||
|
if end == "" {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
st, err := parseDate(normalizeDate(start))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
et, err := parseDate(normalizeDate(end))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, 16)
|
||||||
|
// 防御:区间异常(结束早于开始)或跨度过大时直接放弃,避免死循环
|
||||||
|
if et.Before(st) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if et.Sub(st) > 60*24*time.Hour {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for d := st; !d.After(et); d = d.AddDate(0, 0, 1) {
|
||||||
|
out = append(out, d.Format(dateLayout))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// wageOfHolidayName 依据节日名与日期在假期中的位置推断倍数。
|
||||||
|
// 春节、国庆按"第几天"判断:前 3 天为三薪;其余节日首日为三薪。
|
||||||
|
func wageOfHolidayName(name string, indexInRange int) int8 {
|
||||||
|
switch name {
|
||||||
|
case "春节", "国庆节", "国庆节、中秋节":
|
||||||
|
if indexInRange < 3 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
if indexInRange == 0 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Years 返回内置兜底数据覆盖的年份,用于告知调用方"网络与缓存都没有时
|
||||||
|
// 还能覆盖到哪一年",便于在接口响应里给出提示。
|
||||||
|
func Years() []int {
|
||||||
|
seen := make(map[int]bool, len(yearNotices))
|
||||||
|
out := make([]int, 0, len(yearNotices))
|
||||||
|
for _, n := range yearNotices {
|
||||||
|
if !seen[n.Year] {
|
||||||
|
seen[n.Year] = true
|
||||||
|
out = append(out, n.Year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtinByYear 取某年份的内置数据;年份未收录时返回空切片。
|
||||||
|
func builtinByYear(year int) []Day {
|
||||||
|
out := make([]Day, 0, 32)
|
||||||
|
for _, d := range fallbackData {
|
||||||
|
if d.Year == year {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// annotateWage 依据同一节日的连续区间,为每天补上正确的薪资倍数。
|
||||||
|
// 输入需按日期升序排列;同名的连续日期视为同一次放假。
|
||||||
|
func annotateWage(list []Day) []Day {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
prevName := ""
|
||||||
|
indexInRange := 0
|
||||||
|
for i := range list {
|
||||||
|
if list[i].Type != TypeHoliday {
|
||||||
|
// 调休上班日与普通日期不参与倍数计算,同时打断连续区间
|
||||||
|
prevName = ""
|
||||||
|
indexInRange = 0
|
||||||
|
list[i].Wage = 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if list[i].Name != prevName {
|
||||||
|
prevName = list[i].Name
|
||||||
|
indexInRange = 0
|
||||||
|
} else {
|
||||||
|
indexInRange++
|
||||||
|
}
|
||||||
|
list[i].Wage = wageOfHolidayName(list[i].Name, indexInRange)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupSort 按日期去重并升序排列;同日重复时保留先出现的记录。
|
||||||
|
func dedupSort(list []Day) []Day {
|
||||||
|
seen := make(map[string]int, len(list))
|
||||||
|
out := make([]Day, 0, len(list))
|
||||||
|
for _, d := range list {
|
||||||
|
if d.Date == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if idx, ok := seen[d.Date]; ok {
|
||||||
|
// 已有同日记录:休假日优先于调休上班日(同一日期不会既休又班,
|
||||||
|
// 但外网源偶有脏数据,这里做防御性取舍)
|
||||||
|
if d.Type == TypeHoliday && out[idx].Type == TypeWorkday {
|
||||||
|
out[idx] = d
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[d.Date] = len(out)
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
sortByDate(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortByDate(list []Day) {
|
||||||
|
// 数据量极小(单年最多几十条),直接插入排序,避免引入 sort 包的比较器开销
|
||||||
|
for i := 1; i < len(list); i++ {
|
||||||
|
cur := list[i]
|
||||||
|
j := i - 1
|
||||||
|
for j >= 0 && list[j].Date > cur.Date {
|
||||||
|
list[j+1] = list[j]
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
list[j+1] = cur
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package holiday
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFallbackCovers2026 校验内置兜底数据与国务院办公厅 2026 年通知一致。
|
||||||
|
// 这里的期望值直接抄自官方通知,属于"事实断言":一旦有人改错数据即失败。
|
||||||
|
func TestFallbackCovers2026(t *testing.T) {
|
||||||
|
got := builtinByYear(2026)
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("2026 年内置数据为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
byDate := make(map[string]Day, len(got))
|
||||||
|
for _, d := range got {
|
||||||
|
byDate[d.Date] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// 元旦 1/1-1/3 放假;1/4 上班
|
||||||
|
for _, d := range []string{"2026-01-01", "2026-01-02", "2026-01-03"} {
|
||||||
|
if v, ok := byDate[d]; !ok || v.Type != TypeHoliday {
|
||||||
|
t.Errorf("%s 应为放假日,实际 %+v", d, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := byDate["2026-01-04"]; !ok || v.Type != TypeWorkday {
|
||||||
|
t.Errorf("2026-01-04 应为调休上班日,实际 %+v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 春节 2/15-2/23 放假,共 9 天;2/14、2/28 上班
|
||||||
|
springCount := 0
|
||||||
|
for _, d := range got {
|
||||||
|
if d.Type == TypeHoliday && d.Name == "春节" {
|
||||||
|
springCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if springCount != 9 {
|
||||||
|
t.Errorf("春节放假天数应为 9,实际 %d", springCount)
|
||||||
|
}
|
||||||
|
for _, d := range []string{"2026-02-14", "2026-02-28"} {
|
||||||
|
if v, ok := byDate[d]; !ok || v.Type != TypeWorkday {
|
||||||
|
t.Errorf("%s 应为春节调休上班日,实际 %+v", d, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 国庆 10/1-10/7 放假;9/20、10/10 上班
|
||||||
|
nationalCount := 0
|
||||||
|
for _, d := range got {
|
||||||
|
if d.Type == TypeHoliday && d.Name == "国庆节" {
|
||||||
|
nationalCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nationalCount != 7 {
|
||||||
|
t.Errorf("国庆放假天数应为 7,实际 %d", nationalCount)
|
||||||
|
}
|
||||||
|
for _, d := range []string{"2026-09-20", "2026-10-10"} {
|
||||||
|
if v, ok := byDate[d]; !ok || v.Type != TypeWorkday {
|
||||||
|
t.Errorf("%s 应为国庆调休上班日,实际 %+v", d, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中秋 9/25-9/27、端午 6/19-6/21、清明 4/4-4/6、劳动节 5/1-5/5
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
start string
|
||||||
|
count int
|
||||||
|
}{
|
||||||
|
{"清明节", "2026-04-04", 3},
|
||||||
|
{"劳动节", "2026-05-01", 5},
|
||||||
|
{"端午节", "2026-06-19", 3},
|
||||||
|
{"中秋节", "2026-09-25", 3},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
n := 0
|
||||||
|
for _, d := range got {
|
||||||
|
if d.Type == TypeHoliday && d.Name == c.name {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != c.count {
|
||||||
|
t.Errorf("%s 放假天数应为 %d,实际 %d", c.name, c.count, n)
|
||||||
|
}
|
||||||
|
if v, ok := byDate[c.start]; !ok || v.Type != TypeHoliday {
|
||||||
|
t.Errorf("%s 应为 %s 放假日,实际 %+v", c.start, c.name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFallbackAllYears 校验三年内置数据都能被索引到,且每年都有调休上班日。
|
||||||
|
func TestFallbackAllYears(t *testing.T) {
|
||||||
|
for _, y := range Years() {
|
||||||
|
list := builtinByYear(y)
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Errorf("%d 年内置数据为空", y)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasWork := false
|
||||||
|
for _, d := range list {
|
||||||
|
if d.Type == TypeWorkday {
|
||||||
|
hasWork = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasWork {
|
||||||
|
t.Errorf("%d 年缺少调休上班日数据", y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnnotateWage 校验薪资倍数标注:春节前 3 天三薪,其余两薪。
|
||||||
|
func TestAnnotateWage(t *testing.T) {
|
||||||
|
list := []Day{
|
||||||
|
{Date: "2026-02-15", Name: "春节", Type: TypeHoliday},
|
||||||
|
{Date: "2026-02-16", Name: "春节", Type: TypeHoliday},
|
||||||
|
{Date: "2026-02-17", Name: "春节", Type: TypeHoliday},
|
||||||
|
{Date: "2026-02-18", Name: "春节", Type: TypeHoliday},
|
||||||
|
{Date: "2026-02-19", Name: "春节", Type: TypeHoliday},
|
||||||
|
}
|
||||||
|
got := annotateWage(list)
|
||||||
|
want := []int8{3, 3, 3, 2, 2}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].Wage != w {
|
||||||
|
t.Errorf("%s 薪资倍数应为 %d,实际 %d", got[i].Date, w, got[i].Wage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExpandRange 校验日期区间展开。
|
||||||
|
func TestExpandRange(t *testing.T) {
|
||||||
|
got := expandRange("2026-02-15", "2026-02-23")
|
||||||
|
if len(got) != 9 {
|
||||||
|
t.Fatalf("2/15-2/23 应展开 9 天,实际 %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0] != "2026-02-15" || got[8] != "2026-02-23" {
|
||||||
|
t.Errorf("区间首尾错误: %v", got)
|
||||||
|
}
|
||||||
|
// 单日
|
||||||
|
if one := expandRange("2026-01-01", ""); len(one) != 1 || one[0] != "2026-01-01" {
|
||||||
|
t.Errorf("单日区间展开错误: %v", one)
|
||||||
|
}
|
||||||
|
// 跨月
|
||||||
|
if cross := expandRange("2026-02-28", "2026-03-02"); len(cross) != 3 {
|
||||||
|
t.Errorf("跨月区间展开错误: %v", cross)
|
||||||
|
}
|
||||||
|
// 异常区间不 panic 且返回空
|
||||||
|
if bad := expandRange("2026-03-01", "2026-02-01"); len(bad) != 0 {
|
||||||
|
t.Errorf("结束早于开始应返回空,实际 %v", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDedupSort 校验同日去重时休假日优先于调休上班日。
|
||||||
|
func TestDedupSort(t *testing.T) {
|
||||||
|
list := []Day{
|
||||||
|
{Date: "2026-05-09", Name: "调休上班", Type: TypeWorkday},
|
||||||
|
{Date: "2026-05-01", Name: "劳动节", Type: TypeHoliday},
|
||||||
|
{Date: "2026-05-09", Name: "劳动节", Type: TypeHoliday},
|
||||||
|
}
|
||||||
|
got := dedupSort(list)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("去重后应为 2 条,实际 %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Date != "2026-05-01" {
|
||||||
|
t.Errorf("排序错误,首条应为 05-01,实际 %s", got[0].Date)
|
||||||
|
}
|
||||||
|
if got[1].Type != TypeHoliday {
|
||||||
|
t.Errorf("05-09 同日冲突时应保留休假日,实际 %+v", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchYearLive 联网校验真实数据源可用性(仅测"未收录年份"的联网分支)。
|
||||||
|
// 2024-2026 走内置数据不联网,因此这里用 2023 年验证外网拉取能力;
|
||||||
|
// 若外网不可用则跳过,不把 CI 绑死在第三方接口可用性上。
|
||||||
|
func TestFetchYearLive(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("跳过联网测试")
|
||||||
|
}
|
||||||
|
// 2023 未被内置数据收录,必须联网
|
||||||
|
list, src, err := FetchYear(2023)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("外网不可用,跳过联网测试: %v", err)
|
||||||
|
}
|
||||||
|
if src != SourceHolidayCN {
|
||||||
|
t.Fatalf("2023 年应来自外网源,实际来源 %s", src)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Fatal("2023 年数据为空")
|
||||||
|
}
|
||||||
|
t.Logf("2023 年数据来源: %s,共 %d 条", src, len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuiltinTakesPriority 校验已收录年份走内置数据、完全不联网。
|
||||||
|
// 这是"日历翻月不卡顿"的核心保证:若哪天有人把网络查询挪到内置判断之前,
|
||||||
|
// 本测试会立即失败。
|
||||||
|
func TestBuiltinTakesPriority(t *testing.T) {
|
||||||
|
for _, y := range []int{2024, 2025, 2026} {
|
||||||
|
list, src, err := FetchYear(y)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%d 年获取失败: %v", y, err)
|
||||||
|
}
|
||||||
|
if src != SourceBuiltin {
|
||||||
|
t.Errorf("%d 年应命中内置数据(零网络请求),实际来源 %s", y, src)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Errorf("%d 年数据为空", y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchYearFastForBuiltin 量化验证内置路径的耗时:
|
||||||
|
// 已收录年份必须是纯内存操作(远低于 50ms),而跨境网络请求至少要 300ms。
|
||||||
|
func TestFetchYearFastForBuiltin(t *testing.T) {
|
||||||
|
// 预热,排除首次调用的初始化开销
|
||||||
|
_, _, _ = FetchYear(2026)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
const rounds = 200
|
||||||
|
for i := 0; i < rounds; i++ {
|
||||||
|
if _, src, err := FetchYear(2026); err != nil || src != SourceBuiltin {
|
||||||
|
t.Fatalf("第 %d 次调用异常: src=%s err=%v", i, src, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
perCall := elapsed / rounds
|
||||||
|
if perCall > 50*time.Millisecond {
|
||||||
|
t.Errorf("已收录年份单次耗时应远低于 50ms(不应联网),实际 %v", perCall)
|
||||||
|
}
|
||||||
|
t.Logf("2026 年(内置)单次平均耗时: %v,%d 次共 %v", perCall, rounds, elapsed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package holiday
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 验证 2027 年(国务院通知尚未发布,各数据源为空数组)不会再被误判为失败
|
||||||
|
// 而继续请求被墙的备用源。修复前实测单次约 6 秒,修复后应接近一次 CDN 往返。
|
||||||
|
func TestFetchYear2027NoWasteRetry(t *testing.T) {
|
||||||
|
// 清空内存缓存,确保走完整路径
|
||||||
|
memMu.Lock()
|
||||||
|
memCache = map[int][]Day{}
|
||||||
|
memMu.Unlock()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
list, src, err := FetchYear(2027)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
t.Logf("2027 首次拉取: %v, src=%q, err=%v, 条数=%d", elapsed, src, err, len(list))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("2027 不应返回错误(空年份是合法状态): %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Logf("提示: 2027 已有数据(上游发布了通知),条数=%d", len(list))
|
||||||
|
}
|
||||||
|
if elapsed > 3*time.Second {
|
||||||
|
t.Fatalf("2027 拉取耗时 %v,疑似仍在重试备用源", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次必须命中内存缓存,几乎零耗时
|
||||||
|
start2 := time.Now()
|
||||||
|
list2, _, err2 := FetchYear(2027)
|
||||||
|
elapsed2 := time.Since(start2)
|
||||||
|
t.Logf("2027 二次拉取(走缓存): %v, 条数=%d", elapsed2, len(list2))
|
||||||
|
if err2 != nil {
|
||||||
|
t.Fatalf("缓存命中不应报错: %v", err2)
|
||||||
|
}
|
||||||
|
if elapsed2 > 50*time.Millisecond {
|
||||||
|
t.Fatalf("二次拉取应命中内存缓存,实测 %v", elapsed2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -314,6 +314,9 @@ func RegisterAuthRoutes() {
|
|||||||
beego.Router("/backend/oa/schedule/images/:id", &controllers.BackendOaScheduleController{}, "post:SetImages")
|
beego.Router("/backend/oa/schedule/images/:id", &controllers.BackendOaScheduleController{}, "post:SetImages")
|
||||||
beego.Router("/backend/oa/schedule/content/:id", &controllers.BackendOaScheduleController{}, "post:UpdateContent")
|
beego.Router("/backend/oa/schedule/content/:id", &controllers.BackendOaScheduleController{}, "post:UpdateContent")
|
||||||
|
|
||||||
|
// 中国法定节假日/调休日(日历控件休/班标记,数据全局共享、按年缓存)
|
||||||
|
beego.Router("/backend/oa/holiday", &controllers.BackendOaScheduleController{}, "get:Holidays")
|
||||||
|
|
||||||
// OA通知公告
|
// OA通知公告
|
||||||
beego.Router("/backend/oa/notice/list", &controllers.BackendOaNoticeController{}, "get:List")
|
beego.Router("/backend/oa/notice/list", &controllers.BackendOaNoticeController{}, "get:List")
|
||||||
beego.Router("/backend/oa/notice/portal", &controllers.BackendOaNoticeController{}, "get:Portal")
|
beego.Router("/backend/oa/notice/portal", &controllers.BackendOaNoticeController{}, "get:Portal")
|
||||||
|
|||||||
Reference in New Issue
Block a user