更新日历功能
This commit is contained in:
@@ -83,3 +83,15 @@ export function carryPendingSchedules(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: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
// 新建时预填的标题(如从节假日快捷新建,预填节日名称)
|
||||
prefillTitle: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -367,7 +372,7 @@ function initForm() {
|
||||
}
|
||||
} else {
|
||||
editingId.value = 0;
|
||||
form.title = "";
|
||||
form.title = props.prefillTitle || "";
|
||||
form.schedule_date = props.defaultDate || "";
|
||||
form.all_day = true;
|
||||
form.start_time = "";
|
||||
|
||||
@@ -92,18 +92,27 @@
|
||||
'other-month': !cell.inMonth,
|
||||
today: cell.isToday,
|
||||
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"
|
||||
@dblclick="openCreate(cell.date)"
|
||||
>
|
||||
<div class="cell-date">
|
||||
<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-else-if="cell.day === 1" class="month-tag"
|
||||
>{{ cell.month }}月</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="cell.holidayName" class="cell-holiday" :title="cell.holidayName">
|
||||
{{ cell.holidayName }}
|
||||
</div>
|
||||
<div class="cell-items">
|
||||
<div
|
||||
v-for="item in cell.items.slice(0, 3)"
|
||||
@@ -142,7 +151,20 @@
|
||||
<div class="day-panel">
|
||||
<div class="day-panel-header">
|
||||
<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">
|
||||
{{ selectedItems.length }} 项日程<template v-if="pendingCount > 0"
|
||||
>,{{ pendingCount }} 项未完成</template
|
||||
@@ -152,6 +174,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
v-if="pendingCount > 0"
|
||||
size="small"
|
||||
@@ -366,6 +396,7 @@
|
||||
:schedule="editingItem"
|
||||
:default-date="selectedDate"
|
||||
:draft="pendingDraft"
|
||||
:prefill-title="prefillTitle"
|
||||
@saved="reloadAll"
|
||||
@draft-applied="pendingDraft = null"
|
||||
/>
|
||||
@@ -410,6 +441,7 @@ import {
|
||||
carrySchedule,
|
||||
deleteSchedule,
|
||||
finishSchedule,
|
||||
getScheduleHolidays,
|
||||
getScheduleList,
|
||||
getScheduleStats
|
||||
} from "@/api/oaSchedule";
|
||||
@@ -484,6 +516,120 @@ const calendarMonth = ref(now.getMonth() + 1);
|
||||
const calendarItems = ref([]);
|
||||
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 列,周一起始)
|
||||
const calendarCells = computed(() => {
|
||||
const map = calendarMap.value;
|
||||
@@ -503,7 +649,10 @@ const calendarCells = computed(() => {
|
||||
month: d.getMonth() + 1,
|
||||
inMonth: d.getMonth() + 1 === calendarMonth.value,
|
||||
isToday: date === today,
|
||||
items: map[date] || []
|
||||
items: map[date] || [],
|
||||
// 节假日相关:tag 用于格内角标与整格底色,holidayName 用于节日名文案
|
||||
tag: dayTag(date),
|
||||
holidayName: holidayName(date)
|
||||
});
|
||||
}
|
||||
return cells;
|
||||
@@ -581,9 +730,13 @@ async function loadCalendarData() {
|
||||
start.getMonth(),
|
||||
start.getDate() + 41
|
||||
);
|
||||
const startStr = fmtDate(start);
|
||||
const endStr = fmtDate(end);
|
||||
// 节假日不在这里加载:由上方 watch([calendarYear, calendarMonth]) 统一驱动,
|
||||
// 保证"翻月即加载对应月份所需年份",且首次挂载、二次进入都能自动补齐。
|
||||
const res = await getScheduleList({
|
||||
start_date: fmtDate(start),
|
||||
end_date: fmtDate(end),
|
||||
start_date: startStr,
|
||||
end_date: endStr,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
category: category.value
|
||||
@@ -684,6 +837,8 @@ watch(viewMode, mode => {
|
||||
// ---------- 详情/新增/编辑/操作 ----------
|
||||
const editVisible = ref(false);
|
||||
const editingItem = ref(null);
|
||||
// 新建时预填的标题(节假日快捷新建使用)
|
||||
const prefillTitle = ref("");
|
||||
const detailVisible = ref(false);
|
||||
const detailItem = ref(null);
|
||||
// 从通知设置页返回时恢复的草稿(含提醒设置)
|
||||
@@ -691,12 +846,24 @@ const pendingDraft = ref(null);
|
||||
|
||||
function openCreate(date) {
|
||||
editingItem.value = null;
|
||||
prefillTitle.value = "";
|
||||
if (date) {
|
||||
selectedDate.value = date;
|
||||
}
|
||||
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) {
|
||||
editingItem.value = item;
|
||||
editVisible.value = true;
|
||||
@@ -1174,6 +1341,96 @@ onMounted(() => {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1260,6 +1517,22 @@ onMounted(() => {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
@@ -1381,6 +1654,11 @@ onMounted(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 窄屏下节日名与休/班角标可能挤压日期数字,仅保留角标 */
|
||||
.cell-holiday {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1454,6 +1732,69 @@ html.dark & {
|
||||
.month-tag {
|
||||
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 {
|
||||
background: var(--el-fill-color-light);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/holiday"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"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
|
||||
// 返回今日/本周/本月的日程统计与逾期数,供页面顶部统计卡片展示。
|
||||
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(OaSchedule),
|
||||
new(OaHoliday),
|
||||
new(OaNotice),
|
||||
new(OaDocCategory),
|
||||
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/content/:id", &controllers.BackendOaScheduleController{}, "post:UpdateContent")
|
||||
|
||||
// 中国法定节假日/调休日(日历控件休/班标记,数据全局共享、按年缓存)
|
||||
beego.Router("/backend/oa/holiday", &controllers.BackendOaScheduleController{}, "get:Holidays")
|
||||
|
||||
// OA通知公告
|
||||
beego.Router("/backend/oa/notice/list", &controllers.BackendOaNoticeController{}, "get:List")
|
||||
beego.Router("/backend/oa/notice/portal", &controllers.BackendOaNoticeController{}, "get:Portal")
|
||||
|
||||
Reference in New Issue
Block a user