由 index.html.bak.20260903_043639 重建的历史快照。 时间取自备份文件名(备份生成时刻,Asia/Shanghai)。 此提交只含 index.html,当时其余文件的状态没有对应备份,无法还原。
1037 lines
65 KiB
HTML
1037 lines
65 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||
<title>工时薪酬助手</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||
<style>
|
||
/* ===== 设计令牌(与 Tailwind 默认色板一一对应,便于两种写法混用) ===== */
|
||
:root {
|
||
--ink: #1E293B; /* 主色·深黛蓝 = slate-800 */
|
||
--accent: #4F46E5; /* 强调·靛蓝 = indigo-600 */
|
||
--pos-bg: #ECFDF5; /* 正向·薄荷底 = emerald-50 */
|
||
--pos-fg: #059669; /* 正向·翡翠字 = emerald-600 */
|
||
--neg-bg: #FFF1F2; /* 负向·玫瑰底 = rose-50 */
|
||
--neg-fg: #E11D48; /* 负向·玫瑰字 = rose-600 */
|
||
--page: #F8FAFC; /* 页面底色 = slate-50 */
|
||
--line: #F1F5F9; /* 卡片描边 = slate-100 */
|
||
}
|
||
|
||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||
|
||
body {
|
||
-webkit-tap-highlight-color: transparent;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||
"Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
}
|
||
|
||
/* 等宽数字:账单上下对齐,数值刷新时不左右跳动 */
|
||
.font-mono-num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; }
|
||
|
||
/* 统一卡片:16px 圆角 + 极柔和弥散阴影 */
|
||
.card {
|
||
border-radius: 16px;
|
||
background: #ffffff;
|
||
border: 1px solid var(--line);
|
||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .05), 0 1px 2px -1px rgba(0, 0, 0, .05);
|
||
}
|
||
|
||
/* Lucide 线性图标:尺寸由自身 font-size 决定(用 Tailwind text-· 类控制),
|
||
避免和 Tailwind 的宽高工具类抢层叠顺序。
|
||
注意:注释里不能出现星号加斜杠,否则会提前闭合注释、把下面整条规则吃掉。 */
|
||
.ico {
|
||
width: 1em; height: 1em; flex: none;
|
||
display: inline-block; vertical-align: -.125em;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 1.5;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
/* 上下半月分界:沿网格底缘描出柔和虚线。
|
||
不单独插一行分割元素,那会把下半月推到新行开头、搞乱星期对齐。
|
||
Tailwind CDN 运行时注入的样式可能排在本文件之后,所以用 !important 压住单边颜色。 */
|
||
.cut-edge { border-bottom: 2px dashed rgba(148, 163, 184, .55) !important; }
|
||
|
||
/* 深色看板落影:带品牌色调(靛蓝),比中性黑更贴合渐变底,
|
||
让下方浅色卡片读起来像"从看板底下滑出"而不是被硬线切开。 */
|
||
.board-shadow {
|
||
box-shadow: 0 6px 16px -6px rgba(49, 46, 129, .38),
|
||
0 2px 6px -2px rgba(30, 41, 59, .16);
|
||
}
|
||
|
||
/* 空状态容器:极浅底 + 虚线描边,暗示"这里等着被填上",
|
||
比一行灰字更像一个可以点进去的坑位。 */
|
||
.empty-state {
|
||
border: 1.5px dashed #E2E8F0; /* slate-200 */
|
||
border-radius: 14px;
|
||
background: #FBFCFE;
|
||
}
|
||
|
||
/* 半透明胶囊标签(挂在深色看板上) */
|
||
.pill {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
background: rgba(255, 255, 255, .14);
|
||
border: 1px solid rgba(255, 255, 255, .16);
|
||
border-radius: 999px;
|
||
padding: 4px 10px;
|
||
font-size: 11px; line-height: 1.25; font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.drawer-enter-active, .drawer-leave-active { transition: transform .25s cubic-bezier(.16,1,.3,1), opacity .25s ease; }
|
||
.drawer-enter-from, .drawer-leave-to { transform: translateY(100%); opacity: 0; }
|
||
.fade-enter-active, .fade-leave-active { transition: opacity .15s ease; }
|
||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||
.safe-bottom { padding-bottom: env(safe-area-inset-bottom); }
|
||
</style>
|
||
</head>
|
||
<body class="bg-slate-200">
|
||
|
||
<!-- ===== Lucide 图标 sprite(内联,无外部依赖;描边由 .ico 统一给 1.5) ===== -->
|
||
<svg id="sprite" aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
|
||
<symbol id="i-award" viewBox="0 0 24 24"><path d="m15.477 12.89 1.515 8.526a.5.5 0 0 1-.81.47l-3.58-2.687a1 1 0 0 0-1.197 0l-3.586 2.686a.5.5 0 0 1-.81-.469l1.514-8.526" /><circle cx="12" cy="8" r="6" /></symbol>
|
||
<symbol id="i-briefcase" viewBox="0 0 24 24"><path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" /><rect width="20" height="14" x="2" y="6" rx="2" /></symbol>
|
||
<symbol id="i-calendar-clock" viewBox="0 0 24 24"><path d="M16 14v2.2l1.6 1" /><path d="M16 2v4" /><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5" /><path d="M3 10h5" /><path d="M8 2v4" /><circle cx="16" cy="16" r="6" /></symbol>
|
||
<symbol id="i-calendar-days" viewBox="0 0 24 24"><path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /><path d="M8 14h.01" /><path d="M12 14h.01" /><path d="M16 14h.01" /><path d="M8 18h.01" /><path d="M12 18h.01" /><path d="M16 18h.01" /></symbol>
|
||
<symbol id="i-chart-column" viewBox="0 0 24 24"><path d="M3 3v16a2 2 0 0 0 2 2h16" /><path d="M18 17V9" /><path d="M13 17V5" /><path d="M8 17v-3" /></symbol>
|
||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></symbol>
|
||
<symbol id="i-chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6" /></symbol>
|
||
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
||
<symbol id="i-clock" viewBox="0 0 24 24"><path d="M12 6v6l4 2" /><circle cx="12" cy="12" r="10" /></symbol>
|
||
<symbol id="i-coffee" viewBox="0 0 24 24"><path d="M10 2v2" /><path d="M14 2v2" /><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1" /><path d="M6 2v2" /></symbol>
|
||
<symbol id="i-coins" viewBox="0 0 24 24"><circle cx="8" cy="8" r="6" /><path d="M18.09 10.37A6 6 0 1 1 10.34 18" /><path d="M7 6h1v4" /><path d="m16.71 13.88.7.71-2.82 2.82" /></symbol>
|
||
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol>
|
||
<symbol id="i-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5V19A9 3 0 0 0 21 19V5" /><path d="M3 12A9 3 0 0 0 21 12" /></symbol>
|
||
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m7 10 5 5 5-5" /></symbol>
|
||
<symbol id="i-hourglass" viewBox="0 0 24 24"><path d="M5 22h14" /><path d="M5 2h14" /><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22" /><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2" /></symbol>
|
||
<symbol id="i-lightbulb" viewBox="0 0 24 24"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" /><path d="M9 18h6" /><path d="M10 22h4" /></symbol>
|
||
<symbol id="i-minus" viewBox="0 0 24 24"><path d="M5 12h14" /></symbol>
|
||
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol>
|
||
<symbol id="i-notebook-pen" viewBox="0 0 24 24"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></symbol>
|
||
<symbol id="i-party-popper" viewBox="0 0 24 24"><path d="M5.8 11.3 2 22l10.7-3.79" /><path d="M4 3h.01" /><path d="M22 8h.01" /><path d="M15 2h.01" /><path d="M22 20h.01" /><path d="m22 2-2.24.75a2.9 2.9 0 0 0-1.96 3.12c.1.86-.57 1.63-1.45 1.63h-.38c-.86 0-1.6.6-1.76 1.44L14 10" /><path d="m22 13-.82-.33c-.86-.34-1.82.2-1.98 1.11c-.11.7-.72 1.22-1.43 1.22H17" /><path d="m11 2 .33.82c.34.86-.2 1.82-1.11 1.98C9.52 4.9 9 5.52 9 6.23V7" /><path d="M11 13c1.93 1.93 2.83 4.17 2 5-.83.83-3.07-.07-5-2-1.93-1.93-2.83-4.17-2-5 .83-.83 3.07.07 5 2Z" /></symbol>
|
||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14" /><path d="M12 5v14" /></symbol>
|
||
<symbol id="i-rotate-ccw" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /></symbol>
|
||
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" /></symbol>
|
||
<symbol id="i-shield-alert" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="M12 8v4" /><path d="M12 16h.01" /></symbol>
|
||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" /></symbol>
|
||
<symbol id="i-trash-2" viewBox="0 0 24 24"><path d="M10 11v6" /><path d="M14 11v6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||
<symbol id="i-triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /><path d="M12 9v4" /><path d="M12 17h.01" /></symbol>
|
||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 3v12" /><path d="m17 8-5-5-5 5" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||
<symbol id="i-wallet" viewBox="0 0 24 24"><path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1" /><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4" /></symbol>
|
||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></symbol>
|
||
</svg>
|
||
|
||
<div id="app" class="max-w-md mx-auto min-h-screen bg-slate-50 flex flex-col shadow-xl relative">
|
||
|
||
<!-- ===== 顶部资产看板 ===== -->
|
||
<header class="text-white px-5 pt-5 pb-5 rounded-b-3xl board-shadow shrink-0 relative overflow-hidden"
|
||
style="background-image: radial-gradient(125% 125% at 85% -10%, #6366F1 0%, rgba(99,102,241,0) 58%), linear-gradient(135deg, #1E293B 0%, #312E81 56%, #4F46E5 100%);">
|
||
<!-- 月份切换(日历/汇总) -->
|
||
<div v-if="tab !== 'settings'" class="flex justify-between items-center">
|
||
<div class="flex items-center space-x-1">
|
||
<button @click="changeMonth(-1)" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center"><svg class="ico text-base"><use href="#i-chevron-left" /></svg></button>
|
||
<span class="text-base font-bold tracking-wide font-mono-num">{{ currentYear }}年{{ currentMonth + 1 }}月</span>
|
||
<button @click="changeMonth(1)" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center"><svg class="ico text-base"><use href="#i-chevron-right" /></svg></button>
|
||
</div>
|
||
<button v-if="!isCurrentMonth" @click="goToday" class="pill active:scale-95 transition">回今天</button>
|
||
<span v-else class="text-[11px] text-indigo-100/70 font-medium">{{ tab === 'calendar' ? '打卡' : '汇总' }}</span>
|
||
</div>
|
||
<!-- 设置页标题 -->
|
||
<div v-else class="flex items-center h-7">
|
||
<span class="text-base font-bold tracking-wide flex items-center gap-1.5"><svg class="ico text-[17px]"><use href="#i-settings" /></svg>设置</span>
|
||
</div>
|
||
|
||
<!-- 主数字:本月预计到账(仅日历页)。¥ 与小数位缩小,整数部放大,形成阶梯感 -->
|
||
<div v-if="tab === 'calendar'" class="mt-4">
|
||
<p class="text-[11px] text-indigo-100/70 font-medium flex items-center gap-1.5">
|
||
<svg class="ico text-[13px]"><use href="#i-wallet" /></svg>{{ currentMonth + 1 }}月预计到账
|
||
</p>
|
||
<p class="mt-1 flex items-end font-mono-num leading-none">
|
||
<span class="text-base font-bold text-indigo-100/80 mb-[3px] mr-0.5">¥</span>
|
||
<span class="text-[32px] font-bold tracking-tight">{{ boardMain.int }}</span>
|
||
<span class="text-base font-bold text-indigo-100/80 mb-[3px]">{{ boardMain.frac }}</span>
|
||
</p>
|
||
<p class="mt-1.5 text-[10px] text-indigo-100/60 font-mono-num">
|
||
{{ settings.payDayB }}日 {{ money(payoutThisB) }} · {{ settings.payDayA }}日 {{ money(payoutThisA) }}
|
||
</p>
|
||
|
||
<!-- 半透明胶囊标签 -->
|
||
<div class="mt-3.5 flex flex-wrap gap-1.5">
|
||
<span class="pill">
|
||
<svg class="ico text-[12px] text-indigo-200"><use href="#i-briefcase" /></svg>
|
||
出勤 <b class="font-mono-num font-bold">{{ monthStats.workDays }}</b><span class="text-indigo-100/60">/{{ settings.fullDays }}</span>
|
||
</span>
|
||
<span class="pill">
|
||
<svg class="ico text-[12px] text-indigo-200"><use href="#i-clock" /></svg>
|
||
工时 <b class="font-mono-num font-bold">{{ fmt(monthStats.totalHours) }}</b><span class="text-indigo-100/60">h</span>
|
||
</span>
|
||
<span class="pill">
|
||
<svg class="ico text-[12px] text-indigo-200"><use href="#i-coins" /></svg>
|
||
工时收入 <b class="font-mono-num font-bold text-amber-200">{{ money(monthStats.basePay) }}</b>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ===== 页面容器 ===== -->
|
||
<main class="flex-1 pb-24">
|
||
|
||
<!-- ========== ① 日历打卡 ========== -->
|
||
<section v-show="tab === 'calendar'" class="px-4 pt-5 pb-4">
|
||
<div class="grid grid-cols-7 text-center text-[11px] font-semibold text-slate-400 mb-1.5">
|
||
<span class="text-rose-400">日</span><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span class="text-indigo-400">六</span>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-7 gap-1.5">
|
||
<div v-for="b in firstDayOfWeek" :key="'b'+b" class="h-[64px]"></div>
|
||
|
||
<div v-for="day in daysInMonth" :key="day" @click="openDayDrawer(day)"
|
||
class="h-[64px] rounded-xl p-1 flex flex-col justify-between border relative active:scale-95 transition cursor-pointer overflow-hidden"
|
||
:class="cellClass(day)">
|
||
<div class="flex justify-between items-start">
|
||
<!-- 今天:品牌色实心圆标 -->
|
||
<span v-if="isToday(day)" class="w-[17px] h-[17px] rounded-full bg-indigo-600 text-white text-[10px] font-bold flex items-center justify-center font-mono-num shrink-0">{{ day }}</span>
|
||
<span v-else class="text-[11px] font-bold leading-none font-mono-num text-slate-500 pl-1 pt-1">{{ day }}</span>
|
||
<span class="flex space-x-0.5 pt-1 pr-0.5">
|
||
<span v-if="getRecord(day)?.status === 'work' && getRecord(day)?.contraband_found > 0" class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||
<span v-if="hasPenalty(day)" class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
|
||
</span>
|
||
</div>
|
||
<!-- 徽章统一撑满格宽、统一高度:班次靠左、工时靠右,小数位占固定槽位,整数与 .5 不会错位 -->
|
||
<template v-if="getRecord(day)?.status === 'work'">
|
||
<span class="flex items-center justify-between w-full px-1.5 py-[3px] rounded-md text-[10px] font-bold leading-none"
|
||
:class="getRecord(day).shift_type === 'day' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'">
|
||
<span>{{ getRecord(day).shift_type === 'day' ? '白' : '夜' }}</span>
|
||
<span class="font-mono-num">{{ hInt(getRecord(day).hours) }}<span class="inline-block w-[8px] text-left">{{ hFrac(getRecord(day).hours) }}</span></span>
|
||
</span>
|
||
</template>
|
||
<span v-else-if="getRecord(day)?.status === 'rest'" class="block w-full px-1.5 py-[3px] rounded-md text-[10px] font-bold leading-none text-center bg-slate-200/80 text-slate-500">休</span>
|
||
<span v-else-if="getRecord(day)?.status === 'leave'" class="block w-full px-1.5 py-[3px] rounded-md text-[10px] font-bold leading-none text-center bg-amber-100 text-amber-700">假</span>
|
||
<span v-else class="block w-full px-1 py-[3px] text-[10px] leading-none text-center text-slate-300">—</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 虚线含义说明(取代原来两行带色条的长文本图例) -->
|
||
<div class="mt-3.5 flex items-center justify-center gap-2 text-[10px] text-slate-400">
|
||
<span class="inline-flex items-center gap-1.5">
|
||
<span class="w-4 border-t-2 border-dashed border-slate-300 inline-block"></span>
|
||
<span>线上 → 当月<b class="font-mono-num font-semibold text-slate-500">{{ settings.payDayA }}</b>号发</span>
|
||
</span>
|
||
<span class="text-slate-200">·</span>
|
||
<span>线下 → 次月<b class="font-mono-num font-semibold text-slate-500">{{ settings.payDayB }}</b>号发</span>
|
||
</div>
|
||
|
||
<p class="mt-1.5 text-center text-[10px] text-slate-300">奖励与处罚统一在次月{{ settings.payDayB }}号结算</p>
|
||
</section>
|
||
|
||
<!-- ========== ② 汇总 ========== -->
|
||
<section v-show="tab === 'summary'" class="px-4 pt-5 pb-4 space-y-3.5">
|
||
|
||
<!-- 本月到账 -->
|
||
<div class="card overflow-hidden">
|
||
<div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between">
|
||
<span class="text-xs font-bold text-slate-700 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-wallet" /></svg>本月到账</span>
|
||
<span class="text-[10px] text-slate-400">时薪 {{ settings.rate }} 元/h</span>
|
||
</div>
|
||
<div class="divide-y divide-slate-100">
|
||
<div class="flex items-center justify-between px-5 py-3.5">
|
||
<div class="min-w-0 pr-2">
|
||
<p class="text-xs font-bold text-slate-700">{{ currentMonth + 1 }}月{{ settings.payDayB }}日</p>
|
||
<p class="text-[10px] text-slate-400 mt-0.5">{{ prevLabel.month + 1 }}月下半月 {{ fmt(prevStats.h2) }}h + 奖惩 {{ signed(prevStats.adjustNet) }}</p>
|
||
</div>
|
||
<span class="text-base font-black text-slate-800 shrink-0">{{ money(payoutThisB) }}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between px-5 py-3.5">
|
||
<div class="min-w-0 pr-2">
|
||
<p class="text-xs font-bold text-slate-700">{{ currentMonth + 1 }}月{{ settings.payDayA }}日</p>
|
||
<p class="text-[10px] text-slate-400 mt-0.5">本月上半月 {{ fmt(monthStats.h1) }}h · 纯工时</p>
|
||
</div>
|
||
<span class="text-base font-black text-slate-800 shrink-0">{{ money(payoutThisA) }}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between px-5 py-3 bg-slate-50">
|
||
<span class="text-xs font-bold text-slate-500">本月现金流合计</span>
|
||
<span class="text-lg font-bold text-indigo-600 font-mono-num">{{ money(payoutThisB + payoutThisA) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 本月工时 -->
|
||
<div class="card px-5 py-4">
|
||
<p class="text-xs font-bold text-slate-700 mb-3 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-clock" /></svg>本月工时</p>
|
||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||
<div class="bg-sky-50/70 rounded-xl p-2.5 border border-sky-100">
|
||
<p class="text-[10px] text-sky-700/80">上半月 1–{{ settings.cutoff }}日</p>
|
||
<p class="text-sm font-bold text-sky-700 mt-0.5 font-mono-num">{{ fmt(monthStats.h1) }}h</p>
|
||
<p class="text-[10px] text-slate-400">{{ money(monthStats.h1 * settings.rate) }}</p>
|
||
</div>
|
||
<div class="bg-violet-50/60 rounded-xl p-2.5 border border-violet-100/70">
|
||
<p class="text-[10px] text-violet-700/70">下半月 {{ settings.cutoff + 1 }}日–月底</p>
|
||
<p class="text-sm font-black text-violet-700 mt-0.5">{{ fmt(monthStats.h2) }}h</p>
|
||
<p class="text-[10px] text-slate-400">{{ money(monthStats.h2 * settings.rate) }}</p>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-1.5 text-xs">
|
||
<div class="flex justify-between"><span class="text-slate-500">出勤天数</span><span class="font-bold text-slate-700">{{ monthStats.workDays }} 天(白 {{ monthStats.dayShifts }} / 夜 {{ monthStats.nightShifts }})</span></div>
|
||
<div class="flex justify-between"><span class="text-slate-500">休息 / 请假</span><span class="font-bold text-slate-700">{{ monthStats.restDays }} / {{ monthStats.leaveDays }} 天</span></div>
|
||
<div class="flex justify-between pt-1.5 border-t border-slate-100"><span class="text-slate-500">工时收入合计</span><span class="font-black text-slate-800">{{ money(monthStats.basePay) }}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 本月奖惩(挂账) -->
|
||
<div class="card px-5 py-4">
|
||
<div class="flex items-center justify-between mb-1">
|
||
<p class="text-xs font-bold text-slate-700 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-award" /></svg>本月奖惩</p>
|
||
<span class="text-[10px] font-bold text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">次月{{ settings.payDayB }}号结算</span>
|
||
</div>
|
||
<p class="text-[10px] text-slate-400 mb-2.5">以下金额不计入当月工资</p>
|
||
<div class="space-y-1.5 text-xs">
|
||
<div class="flex justify-between">
|
||
<span class="text-slate-500">满勤奖<span class="text-slate-400">{{ monthStats.workDays >= settings.fullDays ? '(' + fmt(monthStats.totalHours) + 'h×' + settings.fullBonusPerHour + ')' : '(未达' + settings.fullDays + '天)' }}</span></span>
|
||
<span class="font-bold" :class="monthStats.fullBonus > 0 ? 'text-emerald-600' : 'text-slate-400'">{{ monthStats.fullBonus > 0 ? '+' + money(monthStats.fullBonus) : '¥0' }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-slate-500">查获奖励<span class="text-slate-400">({{ monthStats.foundCount }}件)</span></span>
|
||
<span class="font-bold" :class="monthStats.foundBonus > 0 ? 'text-emerald-600' : 'text-slate-400'">{{ monthStats.foundBonus > 0 ? '+' + money(monthStats.foundBonus) : '¥0' }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-slate-500">漏查处罚<span class="text-slate-400">({{ monthStats.missedCount }}件)</span></span>
|
||
<span class="font-bold" :class="monthStats.missedPenalty > 0 ? 'text-rose-600' : 'text-slate-400'">{{ monthStats.missedPenalty > 0 ? '-' + money(monthStats.missedPenalty) : '¥0' }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-slate-500">其他扣款</span>
|
||
<span class="font-bold" :class="monthStats.otherPenalties > 0 ? 'text-rose-600' : 'text-slate-400'">{{ monthStats.otherPenalties > 0 ? '-' + money(monthStats.otherPenalties) : '¥0' }}</span>
|
||
</div>
|
||
<div class="flex justify-between pt-1.5 border-t border-slate-100">
|
||
<span class="text-slate-500">奖惩净额</span>
|
||
<span class="font-black" :class="monthStats.adjustNet >= 0 ? 'text-emerald-700' : 'text-rose-700'">{{ signed(monthStats.adjustNet) }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="penaltyDetails.length" class="mt-3 pt-2.5 border-t border-slate-100 space-y-1">
|
||
<p class="text-[10px] font-bold text-slate-400 mb-1">处罚明细</p>
|
||
<div v-for="d in penaltyDetails" :key="d.day" class="flex justify-between text-[11px]">
|
||
<span class="text-slate-500 truncate pr-2">{{ d.day }}日 {{ d.reason }}</span>
|
||
<span class="text-rose-600 font-bold shrink-0">-{{ money(d.amount) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 次月预估 -->
|
||
<div class="bg-amber-50/70 rounded-2xl border border-amber-200/70 p-4">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<p class="text-xs font-bold text-amber-900">{{ nextLabel.month + 1 }}月{{ settings.payDayB }}日预估</p>
|
||
<span class="text-lg font-black text-amber-700">{{ money(payoutNextB) }}</span>
|
||
</div>
|
||
<div class="space-y-1 text-[11px] text-amber-800/80">
|
||
<div class="flex justify-between"><span>本月下半月工时 {{ fmt(monthStats.h2) }}h</span><span>{{ money(monthStats.h2 * settings.rate) }}</span></div>
|
||
<div class="flex justify-between"><span>本月奖惩结算</span><span>{{ signed(monthStats.adjustNet) }}</span></div>
|
||
</div>
|
||
<p v-if="monthStats.h2 * settings.rate + monthStats.adjustNet < 0" class="mt-2 text-[10px] text-rose-600 font-bold flex items-start gap-1"><svg class="ico text-[13px] mt-px"><use href="#i-triangle-alert" /></svg><span>处罚已超过下半月工时收入,实发按 ¥0 计,差额可能顺延</span></p>
|
||
</div>
|
||
|
||
<!-- 诊断 -->
|
||
<div class="card px-5 py-4">
|
||
<p class="text-xs font-bold text-slate-700 mb-2 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-lightbulb" /></svg>诊断</p>
|
||
<div class="space-y-1.5 text-[11px] leading-relaxed">
|
||
<!-- 空状态:虚线容器 + 淡色图标坑位,点一下就回日历页打卡,比干摆一行灰字有行动指引 -->
|
||
<div v-if="monthStats.workDays === 0" @click="tab = 'calendar'"
|
||
class="empty-state px-4 py-5 flex flex-col items-center gap-2 cursor-pointer active:scale-[.98] transition">
|
||
<span class="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center">
|
||
<svg class="ico text-[20px] text-indigo-400"><use href="#i-notebook-pen" /></svg>
|
||
</span>
|
||
<p class="text-[11px] font-bold text-slate-500">本月还没有打卡记录</p>
|
||
<p class="text-[10px] text-slate-400 text-center leading-relaxed">点这里回日历,选中日期就能记下白班或夜班。<br />记清一天,这里就会算好预估与满勤进度。</p>
|
||
<span class="mt-0.5 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-indigo-600 text-white text-[10px] font-bold">
|
||
<svg class="ico text-[12px]"><use href="#i-calendar-days" /></svg>去打卡
|
||
</span>
|
||
</div>
|
||
<template v-else>
|
||
<p v-if="monthStats.workDays < settings.fullDays" class="text-slate-600">
|
||
距离满勤还差 <b class="text-slate-800">{{ settings.fullDays - monthStats.workDays }} 天</b>,达成可得满勤奖约 <b class="text-emerald-700">{{ money(monthStats.totalHours * settings.fullBonusPerHour) }}</b>({{ nextLabel.month + 1 }}月{{ settings.payDayB }}号发)。
|
||
</p>
|
||
<p v-else class="text-emerald-600 flex items-start gap-1"><svg class="ico text-[13px] mt-px shrink-0"><use href="#i-party-popper" /></svg><span>已达成 {{ settings.fullDays }} 天满勤,满勤奖 <b>+{{ money(monthStats.fullBonus) }}</b> 将于 {{ nextLabel.month + 1 }}月{{ settings.payDayB }}号发放。</span></p>
|
||
<p v-if="monthStats.missedCount > 0" class="text-slate-600">
|
||
本月 {{ monthStats.missedCount }} 次漏查,将扣 <b class="text-rose-600">{{ money(monthStats.missedPenalty) }}</b>,约等于白干 {{ fmt(monthStats.missedPenalty / settings.rate) }} 小时。
|
||
</p>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<button @click="copyReport" class="w-full py-3 bg-slate-800 text-white rounded-2xl text-xs font-bold active:scale-95 transition shadow-sm flex items-center justify-center gap-1.5">
|
||
<svg class="ico text-[15px]"><use :href="copied ? '#i-check' : '#i-copy'" /></svg>{{ copied ? '已复制到剪贴板' : '复制文本报告' }}
|
||
</button>
|
||
</section>
|
||
|
||
<!-- ========== ③ 设置 ========== -->
|
||
<section v-show="tab === 'settings'" class="px-4 pt-5 pb-4 space-y-3.5">
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-coins" /></svg>薪酬规则</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">基础时薪(元/h)</span>
|
||
<input type="number" step="0.5" min="0" v-model.number="settings.rate" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">满勤天数(天)</span>
|
||
<input type="number" step="1" min="1" max="31" v-model.number="settings.fullDays" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">满勤奖(元/工时)</span>
|
||
<input type="number" step="0.5" min="0" v-model.number="settings.fullBonusPerHour" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
</div>
|
||
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">满勤奖按「当月总工时 × 单价」计算,属于奖励,不并入当月工资。</p>
|
||
</div>
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-shield-alert" /></svg>奖惩标准</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">查获违禁品(元/件)</span>
|
||
<input type="number" step="1" min="0" v-model.number="settings.foundBonus" class="w-24 text-right text-xs font-bold text-emerald-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">漏查违禁品(元/件)</span>
|
||
<input type="number" step="1" min="0" v-model.number="settings.missedPenalty" class="w-24 text-right text-xs font-bold text-rose-600 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-calendar-clock" /></svg>发放周期</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">上半月截止日</span>
|
||
<input type="number" step="1" min="1" max="28" v-model.number="settings.cutoff" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">上半月发放日(当月)</span>
|
||
<input type="number" step="1" min="1" max="31" v-model.number="settings.payDayA" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">下半月发放日(次月)</span>
|
||
<input type="number" step="1" min="1" max="31" v-model.number="settings.payDayB" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
</div>
|
||
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
|
||
1–{{ settings.cutoff }}日工时 → 当月{{ settings.payDayA }}号发;{{ settings.cutoff + 1 }}日–月底工时 + 当月奖惩 → 次月{{ settings.payDayB }}号一起发。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-hourglass" /></svg>班次默认工时</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600 flex items-center gap-1.5"><svg class="ico text-[14px] text-amber-500"><use href="#i-sun" /></svg>白班默认(h)</span>
|
||
<input type="number" step="0.5" min="0" max="24" v-model.number="settings.dayHours" class="w-24 text-right text-xs font-bold text-sky-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500 font-mono-num" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600 flex items-center gap-1.5"><svg class="ico text-[14px] text-violet-500"><use href="#i-moon" /></svg>夜班默认(h)</span>
|
||
<input type="number" step="0.5" min="0" max="24" v-model.number="settings.nightHours" class="w-24 text-right text-xs font-bold text-violet-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500 font-mono-num" />
|
||
</label>
|
||
</div>
|
||
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70">夜班跨天,工时全部归入开始打卡的那一天。</p>
|
||
</div>
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-database" /></svg>数据</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<div class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">已存打卡记录</span>
|
||
<span class="text-xs font-bold text-slate-800 font-mono-num">{{ recordCount }} 天</span>
|
||
</div>
|
||
<button @click="exportData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-download" /></svg>导出备份(JSON 文件)</button>
|
||
<button @click="copyData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use :href="dataCopied ? '#i-check' : '#i-copy'" /></svg>{{ dataCopied ? '已复制到剪贴板' : '复制数据到剪贴板' }}</button>
|
||
<label class="flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition cursor-pointer">
|
||
<svg class="ico text-[15px]"><use href="#i-upload" /></svg>导入备份(同日期覆盖)
|
||
<input type="file" accept=".json,application/json" @change="importData" class="hidden" />
|
||
</label>
|
||
<button @click="resetSettings" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-slate-500 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-rotate-ccw" /></svg>恢复默认设置(不动打卡数据)</button>
|
||
<button @click="clearMonth" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-trash-2" /></svg>清空 {{ currentYear }}年{{ currentMonth + 1 }}月记录</button>
|
||
<button @click="clearAll" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-triangle-alert" /></svg>清空全部打卡记录</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="text-center text-[10px] text-slate-400 leading-relaxed pt-1 pb-2">
|
||
数据仅保存在本机浏览器(localStorage)。<br />清除浏览器数据会丢失,建议定期导出备份。
|
||
</p>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<!-- ===== 底部 Tab 导航 ===== -->
|
||
<nav class="fixed bottom-0 left-1/2 -translate-x-1/2 w-full max-w-md bg-white/80 border-t border-slate-200/80 flex safe-bottom z-40" style="backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);">
|
||
<button v-for="t in tabs" :key="t.id" @click="tab = t.id"
|
||
class="flex-1 flex flex-col items-center pt-2 pb-2.5 active:scale-95 transition relative">
|
||
<!-- 选中态指示条:品牌色,贴在顶边 -->
|
||
<span class="absolute top-0 h-[3px] rounded-b-full bg-indigo-600 transition-all duration-300"
|
||
:class="tab === t.id ? 'w-8 opacity-100' : 'w-0 opacity-0'"></span>
|
||
<svg class="ico text-[21px] transition-colors" :class="tab === t.id ? 'text-indigo-600' : 'text-slate-400'"><use :href="'#' + t.icon" /></svg>
|
||
<span class="text-[10px] mt-1 font-bold transition-colors" :class="tab === t.id ? 'text-indigo-600' : 'text-slate-400'">{{ t.label }}</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<!-- ===== 打卡抽屉 ===== -->
|
||
<transition name="drawer">
|
||
<div v-if="isDrawerOpen" class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm" @click.self="isDrawerOpen = false">
|
||
<div class="w-full max-w-md bg-white rounded-t-3xl px-5 pt-5 pb-6 shadow-2xl overflow-y-auto max-h-[88vh] no-scrollbar">
|
||
|
||
<div class="flex justify-between items-start mb-4 pb-3 border-b border-slate-100">
|
||
<div>
|
||
<h3 class="text-base font-bold text-slate-800">{{ currentMonth + 1 }}月{{ selectedDay }}日 打卡</h3>
|
||
<p class="text-[10px] text-slate-400 mt-0.5">
|
||
工时计入
|
||
<b :class="selectedDay <= settings.cutoff ? 'text-sky-600' : 'text-violet-600'">
|
||
{{ selectedDay <= settings.cutoff ? `${currentMonth + 1}月${settings.payDayA}号` : `${nextLabel.month + 1}月${settings.payDayB}号` }}
|
||
</b>
|
||
· 奖惩次月{{ settings.payDayB }}号结算
|
||
</p>
|
||
</div>
|
||
<button @click="isDrawerOpen = false" class="text-slate-300 hover:text-slate-500 leading-none px-1 pt-0.5"><svg class="ico text-lg"><use href="#i-x" /></svg></button>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-3 gap-2 mb-4">
|
||
<button v-for="st in statusOptions" :key="st.id" @click="draftRecord.status = st.id"
|
||
class="py-2.5 rounded-xl font-bold text-xs border-2 transition flex items-center justify-center gap-1.5"
|
||
:class="draftRecord.status === st.id ? 'border-indigo-600 bg-indigo-50 text-indigo-600' : 'border-slate-100 bg-slate-50 text-slate-400'">
|
||
<svg class="ico text-[15px]"><use :href="'#' + st.icon" /></svg>{{ st.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="draftRecord.status === 'work'" class="space-y-3.5">
|
||
<div class="flex space-x-2">
|
||
<button @click="selectShift('day')"
|
||
class="flex-1 py-2.5 rounded-xl text-xs font-bold border transition flex items-center justify-center gap-1.5"
|
||
:class="draftRecord.shift_type === 'day' ? 'bg-sky-600 text-white border-sky-600' : 'bg-slate-50 text-slate-500 border-slate-200'">
|
||
<svg class="ico text-[15px]"><use href="#i-sun" /></svg>白班 <span class="font-mono-num">{{ cfg.dayHours }}h</span>
|
||
</button>
|
||
<button @click="selectShift('night')"
|
||
class="flex-1 py-2.5 rounded-xl text-xs font-bold border transition flex items-center justify-center gap-1.5"
|
||
:class="draftRecord.shift_type === 'night' ? 'bg-violet-600 text-white border-violet-600' : 'bg-slate-50 text-slate-500 border-slate-200'">
|
||
<svg class="ico text-[15px]"><use href="#i-moon" /></svg>夜班 <span class="font-mono-num">{{ cfg.nightHours }}h</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="p-3.5 bg-slate-50 rounded-2xl border border-slate-200/70">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<span class="text-[11px] font-bold text-slate-500">结算工时</span>
|
||
<span class="text-xs font-bold text-indigo-600 bg-white px-2 py-0.5 rounded-lg border border-indigo-100 font-mono-num">{{ draftRecord.hours }} h</span>
|
||
</div>
|
||
<div class="flex overflow-x-auto space-x-1.5 no-scrollbar pb-0.5">
|
||
<button v-for="h in hourOptions" :key="h" @click="draftRecord.hours = h"
|
||
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold shrink-0 transition tabular-nums"
|
||
:class="draftRecord.hours === h ? 'bg-indigo-600 text-white' : 'bg-white text-slate-500 border border-slate-200'">
|
||
{{ h }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-amber-50/60 p-3.5 rounded-2xl border border-amber-100">
|
||
<p class="text-[10px] text-amber-800/80 font-bold mb-2.5 text-center">以下奖惩不计入当月工资,次月{{ settings.payDayB }}号结算</p>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="text-center">
|
||
<p class="text-[10px] font-bold text-emerald-600">查获 +{{ settings.foundBonus }}/件</p>
|
||
<div class="flex justify-center items-center space-x-2 mt-1.5">
|
||
<button @click="draftRecord.contraband_found = Math.max(0, draftRecord.contraband_found - 1)" class="w-7 h-7 bg-white rounded-full shadow-sm border border-slate-200 active:scale-90 flex items-center justify-center text-slate-500"><svg class="ico text-sm"><use href="#i-minus" /></svg></button>
|
||
<span class="font-bold text-sm text-slate-700 w-5 font-mono-num">{{ draftRecord.contraband_found }}</span>
|
||
<button @click="draftRecord.contraband_found++" class="w-7 h-7 bg-emerald-600 text-white rounded-full shadow-sm active:scale-90 flex items-center justify-center"><svg class="ico text-sm"><use href="#i-plus" /></svg></button>
|
||
</div>
|
||
</div>
|
||
<div class="text-center">
|
||
<p class="text-[10px] font-bold text-rose-600">漏查 −{{ settings.missedPenalty }}/件</p>
|
||
<div class="flex justify-center items-center space-x-2 mt-1.5">
|
||
<button @click="draftRecord.contraband_missed = Math.max(0, draftRecord.contraband_missed - 1)" class="w-7 h-7 bg-white rounded-full shadow-sm border border-slate-200 active:scale-90 flex items-center justify-center text-slate-500"><svg class="ico text-sm"><use href="#i-minus" /></svg></button>
|
||
<span class="font-bold text-sm text-slate-700 w-5 font-mono-num">{{ draftRecord.contraband_missed }}</span>
|
||
<button @click="draftRecord.contraband_missed++" class="w-7 h-7 bg-rose-500 text-white rounded-full shadow-sm active:scale-90 flex items-center justify-center"><svg class="ico text-sm"><use href="#i-plus" /></svg></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center space-x-2 mt-3">
|
||
<input type="number" min="0" step="1" v-model.number="draftRecord.other_penalty" placeholder="其他扣款"
|
||
class="w-24 bg-white border border-slate-200 rounded-lg px-2 py-1.5 text-[11px] text-slate-700 outline-none focus:border-indigo-500 font-mono-num" />
|
||
<input type="text" v-model="draftRecord.penalty_reason" placeholder="处罚原因(选填)"
|
||
class="flex-1 bg-white border border-slate-200 rounded-lg px-2 py-1.5 text-[11px] text-slate-700 outline-none focus:border-indigo-500" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-5 flex space-x-2.5">
|
||
<button @click="deleteRecord" class="px-4 py-3 bg-slate-100 text-slate-500 rounded-xl text-xs font-bold active:scale-95 transition flex items-center gap-1.5"><svg class="ico text-sm"><use href="#i-trash-2" /></svg>清除</button>
|
||
<button @click="saveRecord" class="flex-1 py-3 bg-indigo-600 text-white rounded-xl text-sm font-bold shadow-md active:scale-95 transition flex items-center justify-center gap-1.5"><svg class="ico text-base"><use href="#i-check" /></svg>保存打卡</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- ===== 轻提示 ===== -->
|
||
<transition name="fade">
|
||
<div v-if="toast" class="fixed bottom-24 left-1/2 -translate-x-1/2 z-[60] bg-slate-800/95 text-white text-xs font-medium px-4 py-2.5 rounded-full shadow-lg whitespace-nowrap">
|
||
{{ toast }}
|
||
</div>
|
||
</transition>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, ref, reactive, computed, watch, onMounted } = Vue;
|
||
|
||
createApp({
|
||
setup() {
|
||
// ===== 默认规则 =====
|
||
const DEFAULTS = {
|
||
rate: 19.5, // 基础时薪,当月工资只含工时
|
||
fullDays: 27, // 满勤门槛(天)
|
||
fullBonusPerHour: 1, // 满勤奖 = 当月总工时 × 该单价(属奖励,次月结)
|
||
foundBonus: 10, // 查获违禁品 元/件
|
||
missedPenalty: 20, // 漏查违禁品 元/件
|
||
cutoff: 15, // 上半月截止日
|
||
payDayA: 22, // 上半月工时 → 当月该日发
|
||
payDayB: 10, // 下半月工时 + 当月奖惩 → 次月该日发
|
||
dayHours: 8, // 白班默认工时
|
||
nightHours: 10 // 夜班默认工时
|
||
};
|
||
|
||
const settings = reactive({ ...DEFAULTS });
|
||
const records = ref({});
|
||
|
||
// ===== 工时口径:6–12h,0.5 步进 =====
|
||
const HOUR_MIN = 6, HOUR_MAX = 12, HOUR_STEP = 0.5;
|
||
const hourOptions = [];
|
||
for (let h = HOUR_MIN; h <= HOUR_MAX + 1e-9; h += HOUR_STEP) hourOptions.push(Math.round(h * 10) / 10);
|
||
|
||
// 整数部与小数部拆开渲染(仅用于日历格子):小数位占固定宽度槽位,
|
||
// 这样 8 与 8.5 的整数部左右对齐,不会形成错位。
|
||
const hInt = (v) => Math.floor(Number(v) || 0);
|
||
const hFrac = (v) => (Number(v) || 0) % 1 === 0.5 ? '.5' : '';
|
||
|
||
// 仅用于归一「设置里的班次默认工时」,保证默认值落在可选范围内。
|
||
// 不对已保存的打卡记录做任何改写。
|
||
const clampHours = (v, fallback) => {
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return fallback === undefined ? DEFAULTS.dayHours : fallback;
|
||
const snapped = Math.round(n / HOUR_STEP) * HOUR_STEP;
|
||
return Math.min(HOUR_MAX, Math.max(HOUR_MIN, snapped));
|
||
};
|
||
|
||
// ===== 数值兜底:输入框清空 / 脏数据时回落默认值,不让计算变 0 或 NaN =====
|
||
// 注意 Number(null) 与 Number('') 都等于 0,必须先判空,否则清空输入框会把时薪静默变成 0。
|
||
const sn = (v, d) => {
|
||
if (v === null || v === undefined || v === '') return d;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) && n >= 0 ? n : d;
|
||
};
|
||
const cfg = computed(() => ({
|
||
rate: sn(settings.rate, DEFAULTS.rate),
|
||
fullDays: Math.round(sn(settings.fullDays, DEFAULTS.fullDays)) || DEFAULTS.fullDays,
|
||
fullBonusPerHour: sn(settings.fullBonusPerHour, DEFAULTS.fullBonusPerHour),
|
||
foundBonus: sn(settings.foundBonus, DEFAULTS.foundBonus),
|
||
missedPenalty: sn(settings.missedPenalty, DEFAULTS.missedPenalty),
|
||
cutoff: Math.min(28, Math.max(1, Math.round(sn(settings.cutoff, DEFAULTS.cutoff)))),
|
||
dayHours: clampHours(sn(settings.dayHours, DEFAULTS.dayHours), DEFAULTS.dayHours),
|
||
nightHours: clampHours(sn(settings.nightHours, DEFAULTS.nightHours), DEFAULTS.nightHours)
|
||
}));
|
||
|
||
// ===== 页签 =====
|
||
const tab = ref('calendar');
|
||
// icon 存 sprite 里的 symbol id,模板用 <use :href="'#' + icon"> 渲染线性图标。
|
||
// 注意:verify_payroll.js 用例20 断言 tabs 长度=3 且 id 顺序为 calendar,summary,settings,勿改这两项。
|
||
const tabs = [
|
||
{ id: 'calendar', icon: 'i-calendar-days', label: '打卡' },
|
||
{ id: 'summary', icon: 'i-chart-column', label: '汇总' },
|
||
{ id: 'settings', icon: 'i-settings', label: '设置' }
|
||
];
|
||
const statusOptions = [
|
||
{ id: 'work', icon: 'i-briefcase', label: '上班' },
|
||
{ id: 'rest', icon: 'i-coffee', label: '休息' },
|
||
{ id: 'leave', icon: 'i-notebook-pen', label: '请假' }
|
||
];
|
||
|
||
const today = new Date();
|
||
const currentYear = ref(today.getFullYear());
|
||
const currentMonth = ref(today.getMonth());
|
||
const selectedDay = ref(today.getDate());
|
||
|
||
const isDrawerOpen = ref(false);
|
||
const copied = ref(false);
|
||
const dataCopied = ref(false);
|
||
const toast = ref('');
|
||
const showToast = (msg) => {
|
||
toast.value = msg;
|
||
setTimeout(() => { if (toast.value === msg) toast.value = ''; }, 2000);
|
||
};
|
||
|
||
function newDraft(shift) {
|
||
return {
|
||
status: 'work',
|
||
shift_type: shift,
|
||
hours: shift === 'night' ? cfg.value.nightHours : cfg.value.dayHours,
|
||
contraband_found: 0,
|
||
contraband_missed: 0,
|
||
other_penalty: null,
|
||
penalty_reason: ''
|
||
};
|
||
}
|
||
const draftRecord = ref(newDraft('day'));
|
||
|
||
// ===== 日期辅助 =====
|
||
const daysInMonth = computed(() => new Date(currentYear.value, currentMonth.value + 1, 0).getDate());
|
||
const firstDayOfWeek = computed(() => new Date(currentYear.value, currentMonth.value, 1).getDay());
|
||
const monthPrefix = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}-`;
|
||
const keyOf = (y, m, d) => monthPrefix(y, m) + String(d).padStart(2, '0');
|
||
const getRecord = (day) => records.value[keyOf(currentYear.value, currentMonth.value, day)];
|
||
const prevMonthOf = (y, m) => m === 0 ? { year: y - 1, month: 11 } : { year: y, month: m - 1 };
|
||
const nextMonthOf = (y, m) => m === 11 ? { year: y + 1, month: 0 } : { year: y, month: m + 1 };
|
||
|
||
const isToday = (day) => {
|
||
const n = new Date();
|
||
return n.getFullYear() === currentYear.value && n.getMonth() === currentMonth.value && n.getDate() === day;
|
||
};
|
||
const isCurrentMonth = computed(() => {
|
||
const n = new Date();
|
||
return n.getFullYear() === currentYear.value && n.getMonth() === currentMonth.value;
|
||
});
|
||
|
||
const fmt = (n) => (Number(n) || 0).toFixed(1);
|
||
const money = (n) => '¥' + (Number(n) || 0).toFixed(1);
|
||
const signed = (n) => { const v = Number(n) || 0; return (v >= 0 ? '+¥' : '-¥') + Math.abs(v).toFixed(1); };
|
||
|
||
// 金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
|
||
// 必须从 toFixed(1) 的结果上切,不能用 Math.floor 另算,
|
||
// 否则 99.96 会被 money() 进为 100.0、而拆分出 99.0,两处数字对不上。
|
||
const moneyParts = (n) => {
|
||
const v = Number(n) || 0;
|
||
const s = Math.abs(v).toFixed(1);
|
||
const dot = s.indexOf('.');
|
||
return {
|
||
sign: v < 0 ? '-' : '',
|
||
int: s.slice(0, dot).replace(/\B(?=(\d{3})+(?!\d))/g, ','), // 千分位
|
||
frac: s.slice(dot)
|
||
};
|
||
};
|
||
|
||
// ===== 核心聚合:本月 / 上月共用同一套口径 =====
|
||
// 夜班跨天:工时全部归入开始打卡的那一天。
|
||
const aggregateMonth = (year, month) => {
|
||
const c = cfg.value;
|
||
const prefix = monthPrefix(year, month);
|
||
let h1 = 0, h2 = 0, workDays = 0, dayShifts = 0, nightShifts = 0, restDays = 0, leaveDays = 0;
|
||
let foundCount = 0, missedCount = 0, otherPenalties = 0;
|
||
|
||
for (const key in records.value) {
|
||
if (key.indexOf(prefix) !== 0) continue;
|
||
const rec = records.value[key];
|
||
if (!rec) continue;
|
||
if (rec.status === 'rest') { restDays++; continue; }
|
||
if (rec.status === 'leave') { leaveDays++; continue; }
|
||
if (rec.status !== 'work') continue;
|
||
|
||
const day = Number(key.slice(prefix.length));
|
||
const hours = Number(rec.hours || 0);
|
||
if (day <= c.cutoff) h1 += hours; else h2 += hours;
|
||
|
||
workDays++;
|
||
if (rec.shift_type === 'night') nightShifts++; else dayShifts++;
|
||
foundCount += Number(rec.contraband_found || 0);
|
||
missedCount += Number(rec.contraband_missed || 0);
|
||
otherPenalties += Math.max(0, Number(rec.other_penalty || 0));
|
||
}
|
||
|
||
const totalHours = h1 + h2;
|
||
// 奖惩池:满勤奖也算奖励,全部挂账,次月初出结果并随次月工资发放
|
||
const fullBonus = workDays >= c.fullDays ? totalHours * c.fullBonusPerHour : 0;
|
||
const foundBonus = foundCount * c.foundBonus;
|
||
const missedPenalty = missedCount * c.missedPenalty;
|
||
const adjustNet = fullBonus + foundBonus - missedPenalty - otherPenalties;
|
||
|
||
return {
|
||
h1, h2, totalHours, workDays, dayShifts, nightShifts, restDays, leaveDays,
|
||
foundCount, missedCount, otherPenalties,
|
||
fullBonus, foundBonus, missedPenalty, adjustNet,
|
||
basePay: totalHours * c.rate
|
||
};
|
||
};
|
||
|
||
const prevLabel = computed(() => prevMonthOf(currentYear.value, currentMonth.value));
|
||
const nextLabel = computed(() => nextMonthOf(currentYear.value, currentMonth.value));
|
||
const monthStats = computed(() => aggregateMonth(currentYear.value, currentMonth.value));
|
||
const prevStats = computed(() => aggregateMonth(prevLabel.value.year, prevLabel.value.month));
|
||
|
||
// 发放口径:
|
||
// 当月 payDayA = 当月 1–cutoff 工时 × rate
|
||
// 当月 payDayB = 上月 cutoff+1–月底 工时 × rate + 上月奖惩净额
|
||
const payoutThisA = computed(() => Math.max(0, monthStats.value.h1 * cfg.value.rate));
|
||
const payoutThisB = computed(() => Math.max(0, prevStats.value.h2 * cfg.value.rate + prevStats.value.adjustNet));
|
||
const payoutNextB = computed(() => Math.max(0, monthStats.value.h2 * cfg.value.rate + monthStats.value.adjustNet));
|
||
|
||
const penaltyDetails = computed(() => {
|
||
const c = cfg.value;
|
||
const prefix = monthPrefix(currentYear.value, currentMonth.value);
|
||
const out = [];
|
||
for (const key in records.value) {
|
||
if (key.indexOf(prefix) !== 0) continue;
|
||
const r = records.value[key];
|
||
if (!r || r.status !== 'work') continue;
|
||
const missed = Number(r.contraband_missed || 0);
|
||
const other = Math.max(0, Number(r.other_penalty || 0));
|
||
const amount = missed * c.missedPenalty + other;
|
||
if (amount <= 0) continue;
|
||
const parts = [];
|
||
if (missed > 0) parts.push(`漏查${missed}件`);
|
||
if (other > 0) parts.push(r.penalty_reason ? r.penalty_reason : '其他扣款');
|
||
out.push({ day: Number(key.slice(prefix.length)), reason: parts.join('·'), amount });
|
||
}
|
||
return out.sort((a, b) => a.day - b.day);
|
||
});
|
||
|
||
const recordCount = computed(() => Object.keys(records.value).length);
|
||
|
||
// 看板主数字:本月两笔到账合计
|
||
const boardMain = computed(() => moneyParts(payoutThisB.value + payoutThisA.value));
|
||
|
||
const hasPenalty = (day) => {
|
||
const r = getRecord(day);
|
||
return !!r && r.status === 'work' && (Number(r.contraband_missed || 0) > 0 || Number(r.other_penalty || 0) > 0);
|
||
};
|
||
|
||
// 上下半月分界格:7 列网格里 day+7 就在 day 正下方,
|
||
// 所以给 cutoff-6 … cutoff 这 7 格加底虚线,正好描出一条完整分界线,
|
||
// 而且不需要往网格里插额外元素,星期对齐不会被打买。
|
||
const isCutEdge = (day) => {
|
||
const c = cfg.value.cutoff;
|
||
return day <= c && day + 7 > c;
|
||
};
|
||
|
||
const cellClass = (day) => {
|
||
const r = getRecord(day);
|
||
const cls = [];
|
||
if (isCutEdge(day)) cls.push('cut-edge');
|
||
if (isToday(day)) cls.push('ring-2 ring-indigo-500/70');
|
||
if (!r) cls.push('bg-white border-slate-100');
|
||
else if (r.status === 'work') cls.push(r.shift_type === 'night' ? 'bg-violet-50/70 border-violet-100' : 'bg-sky-50/70 border-sky-100');
|
||
else if (r.status === 'rest') cls.push('bg-slate-100/70 border-slate-200');
|
||
else if (r.status === 'leave') cls.push('bg-amber-50/60 border-amber-100');
|
||
else cls.push('bg-white border-slate-100');
|
||
return cls;
|
||
};
|
||
|
||
// ===== 交互 =====
|
||
const changeMonth = (delta) => {
|
||
const t = delta < 0 ? prevMonthOf(currentYear.value, currentMonth.value) : nextMonthOf(currentYear.value, currentMonth.value);
|
||
currentYear.value = t.year;
|
||
currentMonth.value = t.month;
|
||
};
|
||
const goToday = () => {
|
||
const n = new Date();
|
||
currentYear.value = n.getFullYear();
|
||
currentMonth.value = n.getMonth();
|
||
};
|
||
|
||
const openDayDrawer = (day) => {
|
||
selectedDay.value = day;
|
||
const existing = getRecord(day);
|
||
draftRecord.value = existing ? JSON.parse(JSON.stringify(existing)) : newDraft('day');
|
||
isDrawerOpen.value = true;
|
||
};
|
||
|
||
const selectShift = (type) => {
|
||
draftRecord.value.shift_type = type;
|
||
draftRecord.value.hours = type === 'night' ? cfg.value.nightHours : cfg.value.dayHours;
|
||
};
|
||
|
||
const persist = () => localStorage.setItem('work_records', JSON.stringify(records.value));
|
||
|
||
const saveRecord = () => {
|
||
const op = Number(draftRecord.value.other_penalty);
|
||
draftRecord.value.other_penalty = (Number.isFinite(op) && op > 0) ? op : null;
|
||
if (draftRecord.value.status !== 'work') {
|
||
draftRecord.value.contraband_found = 0;
|
||
draftRecord.value.contraband_missed = 0;
|
||
draftRecord.value.other_penalty = null;
|
||
draftRecord.value.penalty_reason = '';
|
||
}
|
||
records.value[keyOf(currentYear.value, currentMonth.value, selectedDay.value)] = JSON.parse(JSON.stringify(draftRecord.value));
|
||
persist();
|
||
isDrawerOpen.value = false;
|
||
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(30);
|
||
showToast('已保存 ' + (currentMonth.value + 1) + '月' + selectedDay.value + '日');
|
||
};
|
||
|
||
const deleteRecord = () => {
|
||
delete records.value[keyOf(currentYear.value, currentMonth.value, selectedDay.value)];
|
||
persist();
|
||
isDrawerOpen.value = false;
|
||
showToast('已清除该日记录');
|
||
};
|
||
|
||
// ===== 复制 / 导入导出 =====
|
||
function writeClipboard(text, ok) {
|
||
const fallback = () => {
|
||
try {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
ok();
|
||
} catch (e) { showToast('复制失败,请改用导出'); }
|
||
};
|
||
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(ok).catch(fallback);
|
||
} else fallback();
|
||
}
|
||
|
||
const buildReport = () => {
|
||
const s = monthStats.value, p = prevStats.value, c = cfg.value;
|
||
const m = currentMonth.value + 1, nm = nextLabel.value.month + 1, pm = prevLabel.value.month + 1;
|
||
return [
|
||
`${currentYear.value}年${m}月工时薪酬复盘(时薪 ${c.rate} 元/h)`,
|
||
`出勤 ${s.workDays}/${c.fullDays} 天(白${s.dayShifts}/夜${s.nightShifts}) | 总工时 ${fmt(s.totalHours)}h`,
|
||
`上半月 ${fmt(s.h1)}h → ${money(s.h1 * c.rate)} 下半月 ${fmt(s.h2)}h → ${money(s.h2 * c.rate)}`,
|
||
``,
|
||
`【本月奖惩·次月${settings.payDayB}号结算】`,
|
||
`满勤奖 ${s.fullBonus > 0 ? '+' + money(s.fullBonus) : '¥0(未满勤)'}`,
|
||
`查获 +${money(s.foundBonus)}(${s.foundCount}件) | 漏查 -${money(s.missedPenalty)}(${s.missedCount}件) | 其他 -${money(s.otherPenalties)}`,
|
||
`奖惩净额 ${signed(s.adjustNet)}`,
|
||
``,
|
||
`【本月到账】`,
|
||
`${m}月${settings.payDayB}日 ${money(payoutThisB.value)}(${pm}月下半月 ${fmt(p.h2)}h + ${pm}月奖惩 ${signed(p.adjustNet)})`,
|
||
`${m}月${settings.payDayA}日 ${money(payoutThisA.value)}(本月上半月 ${fmt(s.h1)}h)`,
|
||
`合计 ${money(payoutThisB.value + payoutThisA.value)}`,
|
||
``,
|
||
`【${nm}月${settings.payDayB}日预估】${money(payoutNextB.value)}(本月下半月 ${fmt(s.h2)}h + 奖惩 ${signed(s.adjustNet)})`
|
||
].join('\n');
|
||
};
|
||
|
||
const copyReport = () => writeClipboard(buildReport(), () => {
|
||
copied.value = true;
|
||
setTimeout(() => copied.value = false, 2000);
|
||
});
|
||
|
||
const bundle = () => JSON.stringify({
|
||
app: 'work-hours-tracker',
|
||
version: 2,
|
||
exportedAt: new Date().toISOString(),
|
||
settings: { ...settings },
|
||
records: records.value
|
||
}, null, 2);
|
||
|
||
const exportData = () => {
|
||
try {
|
||
const blob = new Blob([bundle()], { type: 'application/json' });
|
||
const a = document.createElement('a');
|
||
const d = new Date();
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `工时备份_${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
showToast('已导出备份文件');
|
||
} catch (e) { showToast('导出失败:' + e.message); }
|
||
};
|
||
|
||
const copyData = () => writeClipboard(bundle(), () => {
|
||
dataCopied.value = true;
|
||
setTimeout(() => dataCopied.value = false, 2000);
|
||
showToast('数据已复制');
|
||
});
|
||
|
||
const applyImport = (parsed) => {
|
||
const incoming = parsed && (parsed.records || parsed);
|
||
if (!incoming || typeof incoming !== 'object') throw new Error('格式不正确');
|
||
let n = 0;
|
||
for (const k in incoming) {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(k)) continue;
|
||
records.value[k] = incoming[k];
|
||
n++;
|
||
}
|
||
if (parsed.settings) {
|
||
for (const k in DEFAULTS) {
|
||
if (parsed.settings[k] !== undefined) settings[k] = parsed.settings[k];
|
||
}
|
||
}
|
||
persist();
|
||
return n;
|
||
};
|
||
|
||
const importData = (ev) => {
|
||
const file = ev.target.files && ev.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const n = applyImport(JSON.parse(reader.result));
|
||
showToast(n > 0 ? `已导入 ${n} 天记录` : '文件里没有可用记录');
|
||
} catch (e) { showToast('导入失败:' + e.message); }
|
||
ev.target.value = '';
|
||
};
|
||
reader.readAsText(file);
|
||
};
|
||
|
||
const resetSettings = () => {
|
||
Object.assign(settings, DEFAULTS);
|
||
showToast('设置已恢复默认');
|
||
};
|
||
|
||
const clearMonth = () => {
|
||
const label = `${currentYear.value}年${currentMonth.value + 1}月`;
|
||
if (!confirm(`确定清空 ${label} 的全部打卡记录?此操作不可撤销。`)) return;
|
||
const prefix = monthPrefix(currentYear.value, currentMonth.value);
|
||
let n = 0;
|
||
for (const k in records.value) if (k.indexOf(prefix) === 0) { delete records.value[k]; n++; }
|
||
persist();
|
||
showToast(`已清空 ${label} ${n} 条记录`);
|
||
};
|
||
|
||
const clearAll = () => {
|
||
if (!confirm('确定清空全部打卡记录?建议先导出备份。此操作不可撤销。')) return;
|
||
if (!confirm('再次确认:所有月份的记录都会被删除。')) return;
|
||
records.value = {};
|
||
persist();
|
||
showToast('已清空全部记录');
|
||
};
|
||
|
||
// ===== 载入与持久化 =====
|
||
onMounted(() => {
|
||
const savedRecords = localStorage.getItem('work_records');
|
||
if (savedRecords) { try { records.value = JSON.parse(savedRecords) || {}; } catch (e) {} }
|
||
const savedSettings = localStorage.getItem('work_settings');
|
||
if (savedSettings) {
|
||
try {
|
||
const s = JSON.parse(savedSettings) || {};
|
||
for (const k in DEFAULTS) if (s[k] !== undefined) settings[k] = s[k];
|
||
} catch (e) {}
|
||
}
|
||
});
|
||
|
||
watch(settings, () => localStorage.setItem('work_settings', JSON.stringify({ ...settings })), { deep: true });
|
||
|
||
return {
|
||
settings, cfg, records, recordCount,
|
||
tab, tabs, statusOptions,
|
||
currentYear, currentMonth, selectedDay, daysInMonth, firstDayOfWeek,
|
||
isDrawerOpen, draftRecord, hourOptions,
|
||
HOUR_MIN, HOUR_MAX, HOUR_STEP, hInt, hFrac, clampHours,
|
||
copied, dataCopied, toast,
|
||
monthStats, prevStats, prevLabel, nextLabel,
|
||
payoutThisA, payoutThisB, payoutNextB, penaltyDetails,
|
||
fmt, money, signed, moneyParts, boardMain, buildReport, applyImport,
|
||
isToday, isCurrentMonth, getRecord, hasPenalty, cellClass, isCutEdge,
|
||
changeMonth, goToday, openDayDrawer, selectShift,
|
||
saveRecord, deleteRecord, copyReport,
|
||
exportData, copyData, importData, resetSettings, clearMonth, clearAll
|
||
};
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|