// ==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 = `

${dateStr} 填写休班原因

`; 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();} } })();