// ==UserScript==
// @name 打卡日历
// @namespace http://tampermonkey.net/
// @version 1.5
// @description 右键切换拖动模式;左键点击打开面板;移除自动吸附;自由拖动悬浮图标
// @author You
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @tag 打卡
// @tag 日历
// ==/UserScript==
(function() {
'use strict';
// ===================== 数据模型 =====================
const RECORD_KEY = "work_day_records";
const POS_KEY = "float_button_position";
function getRecordList() {
const str = GM_getValue(RECORD_KEY, "[]");
try {
return JSON.parse(str);
} catch {
return [];
}
}
function saveRecordList(list) {
GM_setValue(RECORD_KEY, JSON.stringify(list));
}
function findRecord(dateStr) {
const list = getRecordList();
return list.find(r => r.date === dateStr);
}
function saveRecord(dateStr, flag) {
const list = getRecordList();
const idx = list.findIndex(r => r.date === dateStr);
if (flag === "") {
if (idx >= 0) list.splice(idx, 1);
} else {
if (idx >= 0) list[idx].restReason = flag;
else list.push({ date: dateStr, restReason: flag });
}
saveRecordList(list);
}
function getTodayStr() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
// ===================== 导入导出 =====================
function exportJson() {
const data = getRecordList();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "work_record_backup.json";
a.click();
URL.revokeObjectURL(url);
}
function importJson(file, callback) {
const reader = new FileReader();
reader.onload = e => {
try {
const arr = JSON.parse(e.target.result);
if (!Array.isArray(arr)) throw new Error("格式错误");
callback(arr);
} catch (err) {
alert("文件解析失败,请使用正确备份文件");
}
};
reader.readAsText(file);
}
// ===================== 样式 =====================
GM_addStyle(`
#workFloatBtn{
position:fixed;right:20px;bottom:20px;z-index:9999999;
padding:10px 16px;background:#2563eb;color:#fff;border:none;border-radius:8px;
cursor:pointer;font-size:14px;box-shadow:0 2px 10px #0003;
transition: background 0.2s;
}
#workFloatBtn.drag-mode{
background:#1d4ed8;
outline: 2px dashed #fff;
}
#workPanelWrap{
position:fixed;right:20px;bottom:70px;width:420px;max-height:85vh;
background:#fff;border-radius:12px;box-shadow:0 4px 20px #0004;
z-index:9999998;overflow:hidden;display:none;
font-family:system-ui;
}
.work-body{padding:16px;overflow-y:auto;max-height:calc(85vh - 30px)}
.calendar-week{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin:8px 0}
.calendar-day{aspect-ratio:1/1;display:flex;align-items:center;justify-content:center;border-radius:6px;border:1px solid #eee;cursor:pointer;font-size:14px}
.calendar-day.work{background:#22c55e;color:#fff;border-color:#22c55e}
.calendar-day.rest{background:#ef4444;color:#fff;border-color:#ef4444}
.calendar-day.today{border:2px solid #2563eb; font-weight:bold}
.calendar-week-title{display:grid;grid-template-columns:repeat(7,1fr);text-align:center;font-size:12px;color:#666;margin-bottom:4px}
.month-operate{display:flex;gap:10px;align-items:center;justify-content:center;margin:10px 0}
.month-operate button{border:none;background:transparent;font-size:18px;cursor:pointer}
.btn-row{display:flex;gap:10px;margin:16px 0 10px}
.btn-row button{flex:1;padding:9px;border:none;border-radius:8px;cursor:pointer}
.btn-export{background:#2563eb;color:#fff}
.btn-import{background:#6b7280;color:#fff}
.dialog-mask{position:fixed;inset:0;background:#0006;display:flex;align-items:center;justify-content:center;z-index:9999999}
.dialog-box{background:#fff;padding:16px;border-radius:10px;width:340px}
.menu-btn{width:100%;padding:10px;margin:4px 0;border:none;border-radius:6px;cursor:pointer;font-size:14px}
.btn-work{background:#22c55e;color:white}
.btn-rest{background:#ef4444;color:white}
.btn-clear{background:#999;color:white}
.input-box{width:100%;box-sizing:border-box;padding:10px;border:1px solid #ddd;border-radius:8px;margin:8px 0}
.divider{margin:16px 0;border-bottom:1px solid #eee}
.rest-title{font-weight:bold;margin-bottom:8px;display:flex;align-items:center;gap:6px}
.rest-item{font-size:13px;padding:4px 0;color:#333}
`);
// ===================== DOM结构 =====================
const floatBtn = document.createElement("button");
floatBtn.id = "workFloatBtn";
floatBtn.textContent = "打卡工具";
document.body.appendChild(floatBtn);
const panel = document.createElement("div");
panel.id = "workPanelWrap";
panel.innerHTML = `
一二三四五六日
📊 当月休班明细
`;
document.body.appendChild(panel);
const calendarWrap = panel.querySelector("#calendarWrap");
const monthTitle = panel.querySelector("#monthTitle");
const prevMonth = panel.querySelector("#prevMonth");
const nextMonth = panel.querySelector("#nextMonth");
const exportBtn = panel.querySelector("#exportBtn");
const importBtn = panel.querySelector("#importBtn");
const fileInput = panel.querySelector("#fileInput");
const restListWrap = panel.querySelector("#restListWrap");
let showPanel = false;
let currentYear = new Date().getFullYear();
let currentMonth = new Date().getMonth();
// 拖拽状态
let dragMode = false; // 是否开启拖动模式
let isDraggingNow = false;
let offsetX, offsetY;
const BOTTOM_SAFE_MARGIN = 60;
// 读取保存位置
let savedPos = GM_getValue(POS_KEY, null);
if(savedPos){
floatBtn.style.left = savedPos.x + "px";
floatBtn.style.top = savedPos.y + "px";
floatBtn.style.right = "auto";
floatBtn.style.bottom = "auto";
}
// 右键切换【拖动模式】,阻止默认右键菜单
floatBtn.addEventListener("contextmenu", e=>{
e.preventDefault();
dragMode = !dragMode;
if(dragMode){
floatBtn.classList.add("drag-mode");
console.log("已开启拖动模式,按住左键拖动图标;再次右键退出");
}else{
floatBtn.classList.remove("drag-mode");
console.log("已关闭拖动模式,左键恢复点击功能");
}
});
// 鼠标按下逻辑
floatBtn.addEventListener("mousedown", e=>{
if(!dragMode) return;
if(e.button !== 0) return;
const rect = floatBtn.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
isDraggingNow = true;
floatBtn.style.cursor = "grabbing";
});
document.addEventListener("mousemove", e=>{
if(!dragMode || !isDraggingNow) return;
const winW = window.innerWidth;
const winH = window.innerHeight;
const btnW = floatBtn.offsetWidth;
const btnH = floatBtn.offsetHeight;
let newX = e.clientX - offsetX;
let newY = e.clientY - offsetY;
// 边界限制
newX = Math.max(0, Math.min(newX, winW - btnW));
newY = Math.max(0, Math.min(newY, winH - btnH - BOTTOM_SAFE_MARGIN));
floatBtn.style.left = newX + "px";
floatBtn.style.top = newY + "px";
floatBtn.style.right = "auto";
floatBtn.style.bottom = "auto";
});
document.addEventListener("mouseup", ()=>{
if(isDraggingNow){
isDraggingNow = false;
floatBtn.style.cursor = "";
// 保存位置
const rect = floatBtn.getBoundingClientRect();
GM_setValue(POS_KEY, {
x: rect.left,
y: rect.top
});
}
});
// 左键点击打开面板(仅非拖动模式生效)
floatBtn.onclick = () => {
if(dragMode) return;
showPanel = !showPanel;
panel.style.display = showPanel ? "block" : "none";
if (showPanel) renderPanel();
};
// 月份切换
prevMonth.onclick = () => {
currentMonth--;
if (currentMonth < 0) { currentMonth = 11; currentYear--; }
renderCalendar();
renderRestList();
};
nextMonth.onclick = () => {
currentMonth++;
if (currentMonth > 11) { currentMonth = 0; currentYear++; }
renderCalendar();
renderRestList();
};
exportBtn.onclick = exportJson;
importBtn.onclick = () => fileInput.click();
fileInput.onchange = ev => {
const f = ev.target.files[0];
if (!f) return;
importJson(f, (newList) => {
const opt = confirm("导入方式:确认覆盖现有记录?取消则为合并导入");
if (opt) saveRecordList(newList);
else {
const old = getRecordList();
const map = {};
old.forEach(r => map[r.date] = r);
newList.forEach(r => map[r.date] = r);
saveRecordList(Object.values(map));
}
renderCalendar();
renderRestList();
alert("导入完成");
fileInput.value = "";
});
};
function renderPanel() {
renderCalendar();
renderRestList();
}
function renderCalendar() {
const year = currentYear;
const month = currentMonth;
const todayStr = getTodayStr();
monthTitle.textContent = `${year}年${month+1}月`;
calendarWrap.innerHTML = "";
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month+1, 0);
let firstWeek = firstDay.getDay() || 7;
for(let i=1;i openSelectMenu(dateStr, rec);
calendarWrap.appendChild(div);
}
}
function renderRestList() {
const year = currentYear;
const month = currentMonth;
const list = getRecordList();
const restItems = list.filter(r=>{
const d = new Date(r.date);
return d.getFullYear() === year && d.getMonth() === month && r.restReason !== "WORK";
}).sort((a,b)=>a.date.localeCompare(b.date));
restListWrap.innerHTML = "";
if(restItems.length === 0){
restListWrap.innerHTML = `本月暂无休班记录
`;
return;
}
restItems.forEach(item=>{
const div = document.createElement("div");
div.className = "rest-item";
div.textContent = `${item.date} | ${item.restReason}`;
restListWrap.appendChild(div);
})
}
function openSelectMenu(dateStr, record){
let mask = document.querySelector(".dialog-mask");
if(mask) mask.remove();
mask = document.createElement("div");
mask.className = "dialog-mask";
mask.innerHTML = `
${dateStr} 选择标记
`;
document.body.appendChild(mask);
mask.querySelector(".btn-work").onclick = ()=>{
saveRecord(dateStr, "WORK");
mask.remove();renderCalendar();renderRestList();
};
mask.querySelector(".btn-clear").onclick = ()=>{
saveRecord(dateStr, "");
mask.remove();renderCalendar();renderRestList();
};
mask.querySelector(".btn-rest").onclick = ()=>{
mask.remove();openRestReasonDialog(dateStr, record?.restReason || "");
};
mask.onclick = e=>{if(e.target === mask) mask.remove();}
}
function openRestReasonDialog(dateStr, oldReason){
let mask = document.querySelector(".dialog-mask");
if(mask) mask.remove();
mask = document.createElement("div");
mask.className = "dialog-mask";
mask.innerHTML = `
`;
document.body.appendChild(mask);
const input = mask.querySelector("#reasonInput");
input.focus();
mask.querySelector("#cancelBtn").onclick = ()=>mask.remove();
mask.querySelector("#saveBtn").onclick = ()=>{
const val = input.value.trim();
if(!val){alert("休班必须填写原因!");return;}
saveRecord(dateStr, val);
mask.remove();renderCalendar();renderRestList();
};
mask.onclick = e=>{if(e.target === mask) mask.remove();}
}
})();