Files
work-hours-tracker/index.html
T
OpenClaw Agent df342c5ee2 snapshot: index.html @ 20260904_074249
由 index.html.bak.20260904_074249 重建的历史快照。
时间取自备份文件名(备份生成时刻,Asia/Shanghai)。
此提交只含 index.html,当时其余文件的状态没有对应备份,无法还原。
2026-09-04 07:42:49 +08:00

1990 lines
126 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234F46E5'/%3E%3Cpath d='M16 8v8l5 3' stroke='white' stroke-width='2.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E" />
<!-- 本地依赖,不走 CDN:
原来用 cdn.tailwindcss.com 是运行时 JIT 编译器,它必须先把页面扫一遍再注入样式,
中间必然有一段无样式窗口;且两个 CDN 在国内不稳,挂了页面就只剩裸模板。
现改为预生成的静态 CSS + 本地 Vue,离线可用、无网络抗手。 -->
<link rel="stylesheet" href="vendor/tailwind.css" />
<script src="vendor/vue.global.prod.js"></script>
<style>
/* Vue 接管前先藏住模板,避开刷新瞬间闪出 {{ }} 原文。
mount() 一执行 v-cloak 属性就被移除,这条规则自动失效。 */
[v-cloak] { visibility: hidden; }
</style>
<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 */
/* 圆角三级阶梯:外层容器 / 内嵌块 / 胶囊标签 */
--r-out: 16px;
--r-in: 10px;
--r-pill: 999px;
/* 统一描边与落影 */
--hair: 1px solid rgba(0, 0, 0, .06);
--lift: 0 4px 20px -2px rgba(0, 0, 0, .05);
}
.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 圆角 + 一根发丝描边 + 大范围弥散落影。
落影用大 blur、小 alpha,读起来是「浮起来」而不是「加了一道灰边」。 */
.card {
border-radius: var(--r-out);
background: #ffffff;
border: var(--hair);
box-shadow: var(--lift);
}
/* 内嵌块(卡片里的小格子):圆角收一档到 10px,形成外大内小的嵌套层级 */
.inset { border-radius: var(--r-in); }
/* 金额柔色徽章:正向薄荷、负向玫瑰、零值中性。
把符号和数字一起裹进底色里,靠色相表意,不再靠一个生硬的加减号。 */
.amt {
display: inline-flex; align-items: center;
padding: 2px 8px;
border-radius: var(--r-pill);
font-size: 11px; line-height: 1.45; font-weight: 700;
font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1;
white-space: nowrap;
}
.amt-pos { background: var(--pos-bg); color: var(--pos-fg); }
.amt-neg { background: var(--neg-bg); color: var(--neg-fg); }
.amt-zero { background: #F1F5F9; color: #94A3B8; }
/* 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; }
/* ===== 发薪日标记 =====
只做"标注"不做"改色":格子底色仍归班次所有(白班橙/夜班靛蓝),
发薪日用内描边 + 右上角 ¥ 字符表达,避免两套语义抢同一块背景。
今天的 ring 优先级更高,遇到重合时只留 ¥ 角标。 */
.pay-ring-a { box-shadow: inset 0 0 0 1.5px rgba(79, 70, 229, .38); }
.pay-ring-b { box-shadow: inset 0 0 0 1.5px rgba(217, 119, 6, .40); }
.pay-mark {
font-size: 9px; font-weight: 800; line-height: 1;
font-variant-numeric: tabular-nums;
}
.pay-mark.pay-a { color: #4f46e5; }
.pay-mark.pay-b { color: #d97706; }
/* 定稿状态标签 */
.stat-tag {
display: inline-flex; align-items: center; gap: 3px;
font-size: 10px; font-weight: 700; line-height: 1;
padding: 3px 7px; border-radius: 999px; white-space: nowrap;
}
.stat-draft { background: #FEF3C7; color: #B45309; }
.stat-final { background: #D1FAE5; color: #047857; }
/* 归属期徽章:标清一笔钱是哪个月挣的。进账列表里上月/本月混排,
原本只写在小灰字副标题里、层级太弱,改用底色色块区分。 */
.own-badge {
display: inline-flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 700; line-height: 1;
padding: 3px 5px; border-radius: 6px; white-space: nowrap; min-width: 32px;
}
.own-prev { background: #E2E8F0; color: #475569; }
.own-cur { background: #E0E7FF; color: #4338CA; }
/* ===== 日历格胶囊结构(第三轮) =====
班次与工时整合为紧凑胶囊,避免 hInt/hFrac 拆分小数导致的折行;
左下实心属性点移除,仅保留右上状态点。 */
.calendar-cell {
display: flex; flex-direction: column;
justify-content: space-between; align-items: center;
height: 56px; padding: 4px 2px; box-sizing: border-box;
}
.cell-top {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 0 4px;
}
.day-num {
font-size: 13px; font-weight: 500; color: #1f2937;
font-variant-numeric: tabular-nums;
}
.event-dot {
width: 5px; height: 5px; border-radius: 50%; background-color: #10b981;
}
.event-dot.rose { background-color: #f43f5e; }
.cell-bottom {
display: flex; justify-content: center; align-items: center; width: 100%;
}
.shift-pill {
display: inline-flex; align-items: baseline; justify-content: center;
gap: 1.5px; padding: 1px 4px; border-radius: 999px;
max-width: 96%; white-space: nowrap; /* 严禁折行:整数/小数都留在同一行 */
}
.shift-pill .shift-type { font-size: 10px; font-weight: 600; line-height: 1; }
.shift-pill .shift-time {
font-size: 10px; font-weight: 600; line-height: 1;
font-variant-numeric: tabular-nums;
}
.shift-night { background-color: #eef2ff; color: #4f46e5; }
.shift-day { background-color: #fff7ed; color: #ea580c; }
.status-text { font-size: 11px; color: #9ca3af; }
.status-text.sky { color: #0ea5e9; }
.status-text.empty { color: #d1d5db; }
.status-text.rest { color: #9ca3af; }
/* 深色看板落影:带品牌色调(靛蓝),比中性黑更贴合渐变底,
让下方浅色卡片读起来像"从看板底下滑出"而不是被硬线切开。 */
.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;
}
/* 半透明胶囊标签(挂在深色看板上)。
flex:none + nowrap 一起用:胶囊既不被压扁也不折行,超宽时由父容器横向滚动兜底。 */
.pill {
display: inline-flex; align-items: center; gap: 3.5px;
flex: none;
background: rgba(255, 255, 255, .14);
border: 1px solid rgba(255, 255, 255, .16);
border-radius: 999px;
padding: 4px 9px;
font-size: 11px; line-height: 1.25; font-weight: 600;
white-space: nowrap;
}
/* 字重阶梯:标题 fw-7 / 数值 fw-6 / 辅助 fw-4,拉大对比 */
.fw-7 { font-weight: 700; }
.fw-6 { font-weight: 600; }
.fw-4 { font-weight: 400; }
.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-cloud" viewBox="0 0 24 24"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
<symbol id="i-refresh-cw" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M8 16H3v5" /></symbol>
<symbol id="i-log-out" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /></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" v-cloak 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 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>
<div class="flex items-center space-x-1">
<button v-if="!isCurrentMonth" @click="goToday" class="pill active:scale-95 transition">回今天</button>
<button @click="openSettings" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center" title="设置"><svg class="ico text-base"><use href="#i-settings" /></svg></button>
</div>
</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-coins" /></svg>{{ currentMonth + 1 }}月总劳动应得
</p>
<!-- 大字旁只放「目标达成状态」,不再放重复金额(金额已在下方平铺算式里) -->
<p class="mt-1 flex items-end flex-wrap gap-y-1 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>
<!-- 达标:金色徽章;未达标:进度激励胶囊。两者都不重复金额。 -->
<span v-if="monthStats.workDays >= settings.fullDays"
class="ml-2 mb-[3px] pill" style="background:rgba(251,191,36,.2); color:#fcd34d; border:1px solid rgba(251,191,36,.4);">
<svg class="ico text-[12px]"><use href="#i-award" /></svg>
<b class="font-bold">已达满勤</b><span class="text-[9px] font-mono-num">¥{{ settings.fullBonusPerHour }}/h</span>
</span>
<span v-else-if="monthStats.workDays > 0" class="ml-2 mb-[3px] pill">
<svg class="ico text-[12px] text-indigo-200"><use href="#i-hourglass" /></svg>
出勤 <b class="font-mono-num font-bold">{{ monthStats.workDays }}/{{ settings.fullDays }}</b>
<span class="text-indigo-100/60">· 差 {{ fullDaysLeft }} 天满勤</span>
</span>
</p>
<!-- 第二层:平铺算式。三项相加严丝合缝等于上方大字,可一眼心算核对。
奖惩与满勤同一口径:未定稿(次月初组长审核前)一律按 ¥0 计,不进大字。 -->
<div class="mt-3 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[11px] font-mono-num text-indigo-100/85">
<span class="whitespace-nowrap">工时 <b class="font-bold text-amber-200">{{ money(monthStats.basePay) }}</b><span class="text-indigo-100/50">({{ fmt(monthStats.totalHours) }}h)</span></span>
<span class="text-indigo-100/45">+</span>
<span class="whitespace-nowrap">满勤 <b class="font-bold text-amber-200">{{ money(monthStats.fullBonus) }}</b></span>
<span class="text-indigo-100/45">+</span>
<span class="whitespace-nowrap">奖惩 <b class="font-bold" :class="monthStats.reviewNet > 0 ? 'text-emerald-300' : (monthStats.reviewNet < 0 ? 'text-rose-300' : 'text-indigo-100/70')">{{ signed(monthStats.reviewNet) }}</b>
<span class="ml-1 text-[9px] font-sans" :class="monthStats.finalized ? 'text-emerald-300' : 'text-indigo-100/55'">{{ monthStats.finalized ? '已定稿' : '未定稿' }}</span>
</span>
</div>
<!-- 第三层:现金流降级为弱化说明条。与上方「应得」量级不同,必须分区,不再挤在一起。 -->
<div class="mt-3 rounded-xl px-3 py-2.5 space-y-1" style="background:rgba(255,255,255,.09); border:1px solid rgba(255,255,255,.13);">
<p class="text-[10px] font-bold text-indigo-100/70 flex items-center gap-1">
<svg class="ico text-[12px]"><use href="#i-wallet" /></svg>这笔钱什么时候到手
</p>
<div class="flex items-center justify-between gap-2 text-[11px]">
<span class="text-indigo-100/70">{{ currentMonth + 1 }}/{{ settings.payDayA }} 上半月纯工时<span class="text-indigo-100/45">({{ payDayAPassed ? '已发' : '待发' }})</span></span>
<b class="font-mono-num font-bold text-indigo-50 shrink-0">{{ money(earnedSplit.paid) }}</b>
</div>
<div class="flex items-center justify-between gap-2 text-[11px]">
<span class="text-indigo-100/70">{{ nextLabel.month + 1 }}/{{ settings.payDayB }} 下半月+奖惩+满勤<span class="text-indigo-100/45">(结转)</span></span>
<b class="font-mono-num font-bold text-indigo-50 shrink-0">{{ money(earnedSplit.carry) }}</b>
</div>
</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-0.5 relative active:scale-95 transition cursor-pointer overflow-hidden"
:class="cellClass(day)">
<div class="calendar-cell">
<!-- 顶部:日期 + 右上状态点(查获绿 / 处罚玫红) -->
<div class="cell-top">
<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="day-num">{{ day }}</span>
<span class="flex items-center space-x-0.5 pt-0.5">
<span v-if="payDayKind(day)" class="pay-mark" :class="payDayKind(day) === 'a' ? 'pay-a' : 'pay-b'">¥</span>
<span v-if="getRecord(day)?.status === 'work' && getRecord(day)?.contraband_found > 0" class="event-dot"></span>
<span v-if="hasPenalty(day)" class="event-dot rose"></span>
</span>
</div>
<!-- 底部:班次+工时胶囊(白=橙/夜=靛蓝),休/假/空为纯文字 -->
<div class="cell-bottom">
<span v-if="getRecord(day)?.status === 'work'"
class="shift-pill"
:class="getRecord(day).shift_type === 'day' ? 'shift-day' : 'shift-night'">
<span class="shift-type">{{ getRecord(day).shift_type === 'day' ? '白' : '夜' }}</span>
<span class="shift-time">{{ fmt(getRecord(day).hours) }}h</span>
</span>
<span v-else-if="getRecord(day)?.status === 'rest'" class="status-text rest">休</span>
<span v-else-if="getRecord(day)?.status === 'leave'" class="status-text sky">假</span>
<span v-else class="status-text empty">—</span>
</div>
</div>
</div>
</div>
<!-- 虚线含义说明:可展开图例(默认收叠,点击展开) -->
<button @click="legendOpen = !legendOpen" class="mt-3.5 mx-auto flex items-center justify-center gap-1 text-[10px] text-slate-400 active:scale-95 transition select-none">
<svg class="ico text-[13px]" :class="legendOpen ? 'rotate-180' : ''" style="transition:transform .2s ease"><use href="#i-chevron-down" /></svg>
<span>图例</span>
</button>
<div v-show="legendOpen" class="mt-1.5 space-y-1">
<div class="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="text-center text-[10px] text-slate-300">奖励与处罚统一在次月{{ settings.payDayB }}号结算</p>
<div class="flex items-center justify-center gap-2.5 text-[10px] text-slate-400 pt-0.5">
<span class="inline-flex items-center gap-1"><b class="pay-mark pay-a">¥</b>当月{{ settings.payDayA }}号发薪</span>
<span class="text-slate-200">·</span>
<span class="inline-flex items-center gap-1"><b class="pay-mark pay-b">¥</b>当月{{ settings.payDayB }}号发薪</span>
</div>
</div>
</section>
<!-- ========== ② 汇总 ========== -->
<section v-show="tab === 'summary'" class="px-4 pt-5 pb-4 space-y-3.5">
<!-- 【归属制】X月挣得:本月干出来的钱,不管什么时候发。
与下面的「进账」卡严格分区,两者不得再共用「本月到账」这种模糊叫法。 -->
<div class="card overflow-hidden">
<div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between">
<span class="text-[13px] font-bold text-slate-800 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-coins" /></svg>{{ currentMonth + 1 }}月总劳动应得</span>
<span class="text-[10px] font-normal text-slate-400">{{ currentMonth + 1 }}/1–{{ currentMonth + 1 }}/{{ daysInMonth }}</span>
</div>
<!-- 三项构成与打卡页平铺算式严格一一对应:工时 + 满勤 + 奖惩 = 合计应得 -->
<div class="px-5 py-3.5 space-y-1.5 text-xs">
<div class="flex justify-between items-center">
<span class="text-slate-500">工时收入<span class="text-slate-400 font-mono-num">({{ fmt(monthStats.totalHours) }}h)</span></span>
<span class="flex items-center gap-1.5">
<span class="stat-tag stat-final">确定</span>
<span class="font-bold text-slate-800 font-mono-num">{{ money(monthStats.basePay) }}</span>
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-slate-500">满勤奖<span class="text-slate-400">{{ monthStats.workDays >= settings.fullDays ? '(已达' + settings.fullDays + '天)' : '(未达' + settings.fullDays + '天,不计)' }}</span></span>
<span class="flex items-center gap-1.5">
<span class="stat-tag" :class="monthStats.workDays >= settings.fullDays ? 'stat-final' : 'stat-draft'">{{ monthStats.workDays >= settings.fullDays ? '已达标' : monthStats.workDays + '/' + settings.fullDays }}</span>
<span class="amt" :class="monthStats.fullBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ monthStats.fullBonus > 0 ? '+' + money(monthStats.fullBonus) : '¥0' }}</span>
</span>
</div>
<!-- 查获只是记录:与满勤未达标同一口径,次月初组长审核定稿前按 ¥0 计入应得 -->
<div class="flex justify-between items-center">
<span class="text-slate-500">奖惩净额<span class="text-slate-400">{{ monthStats.finalized ? '(审核终稿)' : '(未定稿,不计)' }}</span></span>
<span class="flex items-center gap-1.5">
<span class="stat-tag" :class="monthStats.finalized ? 'stat-final' : 'stat-draft'">{{ monthStats.finalized ? '已定稿' : '待次月初审核' }}</span>
<span class="amt" :class="monthStats.reviewNet > 0 ? 'amt-pos' : (monthStats.reviewNet < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.reviewNet) }}</span>
</span>
</div>
<div class="flex justify-between items-center pt-2 border-t border-slate-100">
<span class="font-semibold text-slate-600">合计应得</span>
<span class="text-lg font-bold text-indigo-600 font-mono-num">{{ money(earnedTotal) }}</span>
</div>
<p v-if="!monthStats.finalized" class="text-[10px] text-slate-400 leading-relaxed pt-1">查获在本月只作记录(当前累计 <b class="text-slate-500 font-mono-num">{{ signed(monthStats.rawReview) }}</b>);须次月初组长审核、扣除漏查与其他扣款后提交定稿才算奖金。未定稿一律按 ¥0 计入应得,与未达 {{ settings.fullDays }} 天不计满勤奖同一个道理。</p>
</div>
<!-- 两套口径之间唯一的桥:明说挣得的钱里哪一部分已发、哪一部分要结转。
没有这一行,用户看到挣得 ≠ 进账就会懿。 -->
<div class="px-5 py-3 bg-slate-50 border-t border-slate-100">
<p class="text-[10px] font-bold text-slate-500 mb-1.5 flex items-center gap-1"><svg class="ico text-[12px] text-slate-400"><use href="#i-calendar-clock" /></svg>何时到手</p>
<div class="space-y-1 text-[11px]">
<div class="flex justify-between items-center">
<span class="text-slate-500">{{ currentMonth + 1 }}/{{ settings.payDayA }} 上半月工时<span class="text-slate-400">({{ payDayAPassed ? '已发' : '待发' }})</span></span>
<span class="font-semibold text-slate-700 font-mono-num">{{ money(earnedSplit.paid) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-slate-500">{{ nextLabel.month + 1 }}/{{ settings.payDayB }} 下半月工时+奖惩+满勤<span class="text-slate-400">(结转)</span></span>
<span class="font-semibold text-slate-700 font-mono-num">{{ money(earnedSplit.carry) }}</span>
</div>
</div>
<p v-if="earnedFloored" class="mt-1.5 text-[10px] text-rose-600 font-bold flex items-start gap-1"><svg class="ico text-[13px] mt-px shrink-0"><use href="#i-triangle-alert" /></svg><span>处罚已超过下半月工时收入,结转那笔按 ¥0 计,所以两笔相加不等于合计应得</span></p>
</div>
</div>
<!-- 【收付制】X月进账:本月卡里实际进的钱,含上月结转。
每行最左的归属期徽章是关键:不标就会被看成都是本月挣的。 -->
<div class="card overflow-hidden">
<div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between">
<span class="text-[13px] font-bold text-slate-800 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-wallet" /></svg>{{ currentMonth + 1 }}月进账</span>
<span class="text-[10px] font-normal text-slate-400 font-mono-num">时薪 {{ settings.rate }} 元/h · 实际到账</span>
</div>
<div class="divide-y divide-slate-100">
<div class="px-5 py-3.5">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex items-start gap-2">
<span class="own-badge own-prev mt-px shrink-0">{{ prevLabel.month + 1 }}月</span>
<div class="min-w-0">
<p class="text-xs font-semibold text-slate-700">{{ currentMonth + 1 }}/{{ settings.payDayB }}</p>
<p class="text-[10px] font-normal text-slate-400 mt-0.5">下半月 {{ fmt(prevStats.h2) }}h{{ prevStats.finalized ? ' + 奖惩 ' + signed(prevStats.adjustNet) : '(奖惩未定稿不计)' }}</p>
</div>
</div>
<span class="text-[17px] font-bold text-slate-800 shrink-0 font-mono-num">{{ money(payoutThisB) }}</span>
</div>
<!-- 上月奖惩需在本月 1–2 号汇总申报,所以把上月的定稿入口直接挂在这笔进账下面 -->
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="stat-tag" :class="prevStats.finalized ? 'stat-final' : 'stat-draft'">
<svg class="ico text-[11px]"><use :href="prevStats.finalized ? '#i-check' : '#i-hourglass'" /></svg>
{{ prevLabel.month + 1 }}月奖惩{{ prevStats.finalized ? '已定稿·已计入' : (prevStats.adjustStatus === 'draft' ? '草稿未定稿·不计入' : '待审核·不计入') }}
</span>
<button @click="openAdjust(prevLabel.year, prevLabel.month)"
class="px-3 py-1.5 rounded-xl text-[11px] font-bold active:scale-95 transition flex items-center gap-1.5"
:class="prevStats.finalized ? 'bg-slate-100 text-slate-600' : 'bg-amber-500 text-white shadow-sm'">
<svg class="ico text-[13px]"><use href="#i-notebook-pen" /></svg>{{ prevStats.finalized ? '查看/改稿' : '核对修正' }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-2 px-5 py-3.5">
<div class="min-w-0 flex items-start gap-2">
<span class="own-badge own-cur mt-px shrink-0">{{ currentMonth + 1 }}月</span>
<div class="min-w-0">
<p class="text-xs font-semibold text-slate-700">{{ currentMonth + 1 }}/{{ settings.payDayA }}</p>
<p class="text-[10px] font-normal text-slate-400 mt-0.5">上半月 {{ fmt(monthStats.h1) }}h · 纯工时</p>
</div>
</div>
<span class="text-[17px] font-bold text-slate-800 shrink-0 font-mono-num">{{ money(payoutThisA) }}</span>
</div>
<div class="flex items-center justify-between px-5 py-3 bg-slate-50">
<span class="text-xs font-semibold text-slate-500">合计进账</span>
<span class="text-lg font-bold text-indigo-600 font-mono-num">{{ money(cashTotal) }}</span>
</div>
</div>
</div>
<!-- 本月工时 -->
<div class="card px-5 py-4">
<p class="text-[13px] font-bold text-slate-800 mb-3 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-clock" /></svg>{{ currentMonth + 1 }}月工时</p>
<div class="grid grid-cols-2 gap-2 mb-3">
<div class="inset bg-sky-50/70 p-2.5 border border-sky-100">
<p class="text-[10px] font-normal text-sky-700/80">上半月 1–{{ settings.cutoff }}日</p>
<p class="text-[15px] font-bold text-sky-700 mt-0.5 font-mono-num">{{ fmt(monthStats.h1) }}h</p>
<p class="text-[10px] font-normal text-slate-400 font-mono-num">{{ money(monthStats.h1 * settings.rate) }}</p>
<!-- 归属标注:让「结转」这个概念在页面里反复出现,形成固定词汇 -->
<p class="text-[9px] font-semibold text-sky-700/60 mt-1">本月{{ settings.payDayA }}号发</p>
</div>
<div class="inset bg-violet-50/60 p-2.5 border border-violet-100/70">
<p class="text-[10px] font-normal text-violet-700/70">下半月 {{ settings.cutoff + 1 }}日–月底</p>
<p class="text-[15px] font-bold text-violet-700 mt-0.5 font-mono-num">{{ fmt(monthStats.h2) }}h</p>
<p class="text-[10px] font-normal text-slate-400 font-mono-num">{{ money(monthStats.h2 * settings.rate) }}</p>
<p class="text-[9px] font-semibold text-violet-700/60 mt-1">结转{{ nextLabel.month + 1 }}月{{ settings.payDayB }}号发</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-semibold text-slate-700 font-mono-num">{{ monthStats.workDays }} 天(白 {{ monthStats.dayShifts }} / 夜 {{ monthStats.nightShifts }})</span></div>
<div class="flex justify-between"><span class="text-slate-500">休息 / 请假</span><span class="font-semibold text-slate-700 font-mono-num">{{ monthStats.restDays }} / {{ monthStats.leaveDays }} 天</span></div>
<div class="flex justify-between pt-2 border-t border-slate-100"><span class="font-semibold text-slate-600">工时收入合计</span><span class="font-bold text-slate-800 font-mono-num">{{ 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-[13px] font-bold text-slate-800 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-award" /></svg>{{ currentMonth + 1 }}月奖惩记录</p>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="monthStats.finalized ? 'text-emerald-700 bg-emerald-100' : 'text-amber-700 bg-amber-100'">{{ monthStats.finalized ? '已定稿·次月' + settings.payDayB + '号发' : '待次月初组长审核' }}</span>
</div>
<p class="text-[10px] font-normal text-slate-400 mb-2.5">下方为日常记录累计,定稿前不计入任何金额口径</p>
<div class="space-y-1.5 text-xs">
<div class="flex justify-between items-center">
<span class="text-slate-500">查获记录<span class="text-slate-400">({{ monthStats.foundCount }}件)</span></span>
<span class="amt" :class="monthStats.foundBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ monthStats.foundBonus > 0 ? '+' + money(monthStats.foundBonus) : '¥0' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-slate-500">漏查处罚<span class="text-slate-400">({{ monthStats.missedCount }}件)</span></span>
<span class="amt" :class="monthStats.missedPenalty > 0 ? 'amt-neg' : 'amt-zero'">{{ monthStats.missedPenalty > 0 ? '-' + money(monthStats.missedPenalty) : '¥0' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-slate-500">其他扣款</span>
<span class="amt" :class="monthStats.otherPenalties > 0 ? 'amt-neg' : 'amt-zero'">{{ monthStats.otherPenalties > 0 ? '-' + money(monthStats.otherPenalties) : '¥0' }}</span>
</div>
<!-- 待申报小计:这是次月 1–2 号需要汇总上报、并可能被审核改数的部分 -->
<div class="flex justify-between items-center pt-2 border-t border-slate-100">
<span class="text-slate-500 flex items-center gap-1.5">
日常累计
<span class="text-[10px] text-slate-400">(待申报)</span>
</span>
<span class="amt" :class="monthStats.rawReview > 0 ? 'amt-pos' : (monthStats.rawReview < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.rawReview) }}</span>
</div>
<div v-if="monthStats.finalized" class="flex justify-between items-center">
<span class="text-slate-500 flex items-center gap-1.5">
审核终稿
<span v-if="monthStats.reviewDelta !== 0" class="text-[10px]" :class="monthStats.reviewDelta > 0 ? 'text-emerald-600' : 'text-rose-500'">
{{ monthStats.reviewDelta > 0 ? '上调' : '下调' }}{{ money(Math.abs(monthStats.reviewDelta)) }}
</span>
</span>
<span class="amt" :class="monthStats.reviewAmount > 0 ? 'amt-pos' : (monthStats.reviewAmount < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.reviewAmount) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-slate-500">满勤奖<span class="text-slate-400">{{ monthStats.workDays >= settings.fullDays ? '(' + fmt(monthStats.totalHours) + 'h×' + settings.fullBonusPerHour + ',下月' + (nextLabel.month + 1) + '号发)' : '(未达' + settings.fullDays + '天,不计)' }}</span></span>
<span class="amt" :class="monthStats.fullBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ monthStats.fullBonus > 0 ? '+' + money(monthStats.fullBonus) : '¥0' }}</span>
</div>
<!-- 本卡内已经列了满勤奖,所以尾行合计必须把两笔已确认的钱加在一起:
= 满勤奖(达标才计)+ 奖惩净额(定稿才计),正好是次月 payDayB 除工时外多发的部分 -->
<div class="flex justify-between items-center pt-2 border-t border-slate-100">
<span class="font-semibold text-slate-600">计入应得合计<span class="text-[10px] text-slate-400">(满勤+奖惩)</span></span>
<span class="amt text-[12px]" :class="monthStats.adjustNet > 0 ? 'amt-pos' : (monthStats.adjustNet < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.adjustNet) }}</span>
</div>
</div>
<!-- 修正入口:日常记录通道不变,这里只改「待申报」总额 -->
<div class="mt-3 pt-2.5 border-t border-slate-100 flex items-center justify-between gap-2">
<span class="stat-tag" :class="monthStats.finalized ? 'stat-final' : 'stat-draft'">
<svg class="ico text-[11px]"><use :href="monthStats.finalized ? '#i-check' : '#i-hourglass'" /></svg>
{{ monthStats.finalized ? '已定稿·已计入' : (monthStats.adjustStatus === 'draft' ? '草稿未定稿·不计入' : '仅记录·不计入') }}
</span>
<button @click="openAdjust(currentYear, currentMonth)"
class="px-3 py-1.5 rounded-xl text-[11px] font-bold active:scale-95 transition flex items-center gap-1.5"
:class="monthStats.finalized ? 'bg-slate-100 text-slate-600' : 'bg-indigo-600 text-white shadow-sm'">
<svg class="ico text-[13px]"><use href="#i-notebook-pen" /></svg>{{ monthStats.finalized ? '查看/改稿' : '核对修正' }}
</button>
</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 items-center text-[11px]">
<span class="text-slate-500 truncate pr-2">{{ d.day }}日 {{ d.reason }}</span>
<span class="amt amt-neg 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">
<div>
<p class="text-xs font-bold text-amber-900">结转到 {{ nextLabel.month + 1 }}月{{ settings.payDayB }}日</p>
<p class="text-[10px] font-normal text-amber-700/60 mt-0.5">{{ currentMonth + 1 }}月总应得里未发的部分 · 与实际到账同额</p>
</div>
<span class="text-lg font-black text-amber-700 font-mono-num">{{ money(earnedCarry) }}</span>
</div>
<!-- 三行相加 = 次月 payDayB 实际到账(满勤奖也在这天发,必须列进来) -->
<div class="space-y-1 text-[11px] text-amber-800/80">
<div class="flex justify-between items-center"><span>{{ currentMonth + 1 }}月下半月工时 {{ fmt(monthStats.h2) }}h</span><span class="font-mono-num font-semibold">{{ money(monthStats.h2 * settings.rate) }}</span></div>
<div class="flex justify-between items-center"><span>{{ currentMonth + 1 }}月满勤奖</span><span class="amt" :class="monthStats.fullBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ monthStats.fullBonus > 0 ? '+' + money(monthStats.fullBonus) : '¥0' }}</span></div>
<div class="flex justify-between items-center"><span>{{ currentMonth + 1 }}月奖惩结算{{ monthStats.finalized ? '' : '(未定稿不计)' }}</span><span class="amt" :class="monthStats.reviewNet > 0 ? 'amt-pos' : (monthStats.reviewNet < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.reviewNet) }}</span></div>
<div class="flex justify-between items-center pt-0.5">
<span class="text-amber-700/70">奖惩状态</span>
<span class="font-semibold" :class="monthStats.finalized ? 'text-emerald-700' : 'text-amber-700'">{{ monthStats.finalized ? '已定稿' : '仅记录 ' + signed(monthStats.rawReview) + '·待审核' }}</span>
</div>
</div>
<p v-if="earnedFloored" 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>。
<span class="text-slate-400">若达成,满勤奖为<b class="text-emerald-700">{{ money(monthStats.totalHours * settings.fullBonusPerHour) }}</b>(当月总工时 {{ fmt(monthStats.totalHours) }}h × {{ settings.fullBonusPerHour }} 元/h),下月{{ settings.payDayB }}号单独到账,不计入当月工资。</span>
</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>
</main>
<!-- ===== 设置抽屉 ===== -->
<transition name="drawer">
<div v-if="settingsOpen" class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm" @click.self="closeSettings">
<div class="w-full max-w-md bg-white rounded-t-3xl shadow-2xl flex flex-col">
<!-- 抽屉把手 -->
<div class="pt-2.5 pb-1 flex justify-center shrink-0" @click="closeSettings">
<div class="w-[54px] h-[5px] rounded-full bg-slate-300"></div>
</div>
<!-- 标题 + 关闭 -->
<div class="flex justify-between items-center px-5 pb-3 shrink-0">
<span class="text-lg font-bold text-slate-800 flex items-center gap-1.5"><svg class="ico text-[20px] text-indigo-600"><use href="#i-settings" /></svg>设置</span>
<button @click="closeSettings" 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="overflow-y-auto no-scrollbar max-h-[72vh] px-4 pb-8 pt-1 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">
<div class="px-5 py-3 flex items-center justify-between">
<span class="text-xs text-slate-600 flex items-center gap-1.5"><svg class="ico text-[14px] text-indigo-500"><use href="#i-clock" /></svg>打卡默认班次</span>
<div class="flex rounded-xl overflow-hidden border border-slate-200">
<button @click="settings.defaultShift = 'day'"
class="px-3 py-1.5 text-[11px] font-bold transition flex items-center gap-1"
:class="cfg.defaultShift === 'day' ? 'bg-sky-600 text-white' : 'bg-white text-slate-500'">
<svg class="ico text-[13px]"><use href="#i-sun" /></svg>白班
</button>
<button @click="settings.defaultShift = 'night'"
class="px-3 py-1.5 text-[11px] font-bold transition flex items-center gap-1"
:class="cfg.defaultShift === 'night' ? 'bg-violet-600 text-white' : 'bg-white text-slate-500'">
<svg class="ico text-[13px]"><use href="#i-moon" /></svg>夜班
</button>
</div>
</div>
<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">
<div class="px-5 py-3 bg-slate-50 border-b border-slate-100 flex items-center justify-between gap-2">
<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-cloud" /></svg>云端同步</p>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="syncStatusClass">{{ syncStatusText }}</span>
</div>
<!-- 已登录 -->
<div v-if="sync.user" 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">{{ sync.user.username }}</span>
</div>
<div v-if="syncTimeText" 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">{{ syncTimeText }}</span>
</div>
<div v-if="sync.lastError" class="px-5 py-2.5 bg-rose-50">
<p class="text-[10px] text-rose-700 leading-relaxed">{{ sync.lastError }}</p>
</div>
<button @click="doSyncNow" :disabled="sync.busy" 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 disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-refresh-cw" /></svg>立即同步{{ sync.pending > 0 ? '(待上传 ' + sync.pending + ' 项)' : '' }}</button>
<button @click="doPushAll" :disabled="sync.busy" 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 disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-upload" /></svg>把本机全部数据上传</button>
<button @click="doPullAll" :disabled="sync.busy" 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 disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-download" /></svg>用服务器数据覆盖本机</button>
<button @click="doLogout" :disabled="sync.busy" 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 disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-log-out" /></svg>退出登录(保留本地数据)</button>
</div>
<!-- 未登录 -->
<div v-else class="divide-y divide-slate-100">
<div class="px-5 py-3 space-y-2">
<input v-model="loginForm.username" type="text" autocomplete="username" placeholder="用户名(3–32 位字母数字)"
class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" />
<input v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="密码(至少 8 位)" @keyup.enter="doLogin"
class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" />
<div class="flex gap-2 pt-0.5">
<button @click="doLogin" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-indigo-600 text-white active:scale-95 transition disabled:opacity-40">登录</button>
<button v-if="sync.serverUsers === 0" @click="doRegister" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white active:scale-95 transition disabled:opacity-40">创建首个账号</button>
</div>
</div>
<p class="px-5 py-2.5 text-[10px] leading-relaxed bg-slate-50/70"
:class="sync.serverUsers === null ? 'text-amber-700' : 'text-slate-400'">
<template v-if="sync.serverUsers === null">没探到同步服务(当前只跑静态页)。数据仍存本机,一切功能正常;要多设备共用需先启动 server/。</template>
<template v-else-if="sync.serverUsers === 0">服务端还没有账号。创建首个账号后就会自动关闭注册,不会被陌生人开号。</template>
<template v-else>登录后打卡数据会自动同步到服务器,多设备共用同一份。未登录也能正常记账,只存本机。</template>
</p>
<label class="flex items-center justify-between gap-2 px-5 py-3">
<span class="text-xs text-slate-600 shrink-0">服务器地址</span>
<input v-model="sync.apiBase" @change="saveApiBase" type="url" inputmode="url" placeholder="留空 = 同源"
class="min-w-0 flex-1 text-right text-[11px] text-slate-700 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-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">
<template v-if="sync.user">数据存本机浏览器,并同步到服务器账号 <b class="text-slate-500">{{ sync.user.username }}</b>。<br />换设备登录同一账号即可看到同一份数据。</template>
<template v-else>数据仅保存在本机浏览器(localStorage)。<br />清除浏览器数据会丢失,建议登录同步或定期导出备份。</template>
</p>
</div>
</div>
</div>
</transition>
<!-- ===== 底部 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"
:class="t.id === 'settings' ? '' : ''"
@click="t.id === 'settings' ? openSettings() : (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>
</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 v-if="selectedPayInfo" class="mb-4 rounded-2xl px-4 py-3 flex items-center justify-between gap-3"
:class="selectedPayInfo.kind === 'a' ? 'bg-indigo-50 border border-indigo-100' : 'bg-amber-50 border border-amber-100'">
<div class="min-w-0">
<p class="text-[11px] font-bold flex items-center gap-1.5" :class="selectedPayInfo.kind === 'a' ? 'text-indigo-700' : 'text-amber-800'">
<svg class="ico text-[14px]"><use href="#i-wallet" /></svg>今日到账
<span v-if="!selectedPayInfo.finalized" class="stat-tag stat-draft">奖惩待定稿</span>
</p>
<p class="text-[10px] mt-0.5 truncate" :class="selectedPayInfo.kind === 'a' ? 'text-indigo-500/80' : 'text-amber-700/80'">{{ selectedPayInfo.desc }}</p>
</div>
<span class="text-base font-black shrink-0 font-mono-num" :class="selectedPayInfo.kind === 'a' ? 'text-indigo-700' : 'text-amber-700'">{{ money(selectedPayInfo.amount) }}</span>
</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">以下只是记录,需次月初组长审核定稿后才算金额</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="drawer">
<div v-if="adjustOpen" class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm" @click.self="closeAdjust">
<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 flex items-center gap-1.5">
<svg class="ico text-[18px] text-indigo-600"><use href="#i-shield-alert" /></svg>
{{ adjustTarget.year }}年{{ adjustTarget.month + 1 }}月 奖惩核对
</h3>
<p class="text-[10px] text-slate-400 mt-0.5">次月1–2号汇总申报,以审核终稿为准 · 随{{ settings.payDayB }}号工资发放</p>
</div>
<button @click="closeAdjust" 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="inset bg-slate-50 border border-slate-200/70 p-3.5 mb-3">
<p class="text-[11px] font-bold text-slate-500 mb-2 flex items-center gap-1.5">
<svg class="ico text-[13px] text-slate-400"><use href="#i-notebook-pen" /></svg>日常打卡累计(只读)
</p>
<div class="space-y-1 text-[11px]">
<div class="flex justify-between"><span class="text-slate-500">查获奖励({{ adjustStats.foundCount }}件)</span><span class="amt" :class="adjustStats.foundBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ adjustStats.foundBonus > 0 ? '+' + money(adjustStats.foundBonus) : '¥0' }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">漏查处罚({{ adjustStats.missedCount }}件)</span><span class="amt" :class="adjustStats.missedPenalty > 0 ? 'amt-neg' : 'amt-zero'">{{ adjustStats.missedPenalty > 0 ? '-' + money(adjustStats.missedPenalty) : '¥0' }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">其他扣款</span><span class="amt" :class="adjustStats.otherPenalties > 0 ? 'amt-neg' : 'amt-zero'">{{ adjustStats.otherPenalties > 0 ? '-' + money(adjustStats.otherPenalties) : '¥0' }}</span></div>
<div class="flex justify-between pt-1.5 mt-1 border-t border-slate-200/80">
<span class="font-bold text-slate-600">累计小计</span>
<span class="amt text-[12px]" :class="adjustStats.rawReview > 0 ? 'amt-pos' : (adjustStats.rawReview < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(adjustStats.rawReview) }}</span>
</div>
</div>
<p class="text-[10px] text-slate-400 mt-2 leading-relaxed">满勤奖由出勤天数客观判定,不在核对范围内(当前 {{ adjustStats.workDays }}/{{ settings.fullDays }} 天,{{ adjustStats.fullBonus > 0 ? '+' + money(adjustStats.fullBonus) : '未达标' }})。</p>
</div>
<!-- 审核终稿(可编辑) -->
<div class="inset bg-indigo-50/60 border border-indigo-100 p-3.5 mb-3">
<p class="text-[11px] font-bold text-indigo-700 mb-2 flex items-center gap-1.5">
<svg class="ico text-[13px]"><use href="#i-award" /></svg>审核终稿金额
</p>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-500">¥</span>
<input type="number" step="0.5" v-model.number="adjustDraft.finalAmount" placeholder="填写最终审核金额"
class="flex-1 bg-white border border-indigo-200 rounded-xl px-3 py-2 text-sm font-bold text-slate-800 outline-none focus:border-indigo-500 font-mono-num" />
<button @click="adjustDraft.finalAmount = Math.round(adjustStats.rawReview * 10) / 10"
class="px-2.5 py-2 bg-white border border-indigo-200 rounded-xl text-[10px] font-bold text-indigo-600 active:scale-95 transition shrink-0">按累计</button>
</div>
<p class="text-[10px] text-indigo-500/80 mt-1.5">奖多于罚填正数,罚多于奖填负数。例:累计 {{ signed(adjustStats.rawReview) }},审核只认 80 就填 80。</p>
<div v-if="adjustDelta !== null && adjustDelta !== 0" class="mt-2.5 pt-2.5 border-t border-indigo-100 flex justify-between items-center text-[11px]">
<span class="text-slate-500">与累计差额</span>
<span class="amt" :class="adjustDelta > 0 ? 'amt-pos' : 'amt-neg'">{{ adjustDelta > 0 ? '上调 +' : '下调 -' }}{{ money(Math.abs(adjustDelta)) }}</span>
</div>
<input type="text" v-model="adjustDraft.note" maxlength="60" placeholder="备注(如:与班长核对后确认)"
class="w-full mt-2.5 bg-white border border-indigo-200 rounded-xl px-3 py-2 text-[11px] text-slate-700 outline-none focus:border-indigo-500" />
</div>
<!-- 生效后的口径预览 -->
<div class="inset bg-amber-50/70 border border-amber-100 p-3.5 mb-4">
<div class="flex justify-between items-center text-[11px]">
<span class="text-amber-800/80">定稿后奖惩净额(含满勤奖)</span>
<span class="amt text-[12px]" :class="(Number(adjustDraft.finalAmount) || 0) + adjustStats.fullBonus > 0 ? 'amt-pos' : ((Number(adjustDraft.finalAmount) || 0) + adjustStats.fullBonus < 0 ? 'amt-neg' : 'amt-zero')">{{ signed((Number(adjustDraft.finalAmount) || 0) + adjustStats.fullBonus) }}</span>
</div>
<p class="text-[10px] text-amber-700/70 mt-1">该金额将并入 {{ adjustTarget.month === 11 ? adjustTarget.year + 1 : adjustTarget.year }}年{{ adjustTarget.month === 11 ? 1 : adjustTarget.month + 2 }}月{{ settings.payDayB }}号发放。</p>
</div>
<div v-if="adjustStats.finalized" class="flex items-center gap-1.5 mb-3 text-[10px] text-emerald-700">
<svg class="ico text-[13px]"><use href="#i-check" /></svg>
<span>已于 {{ (adjustStats.finalizedAt || '').slice(0, 16).replace('T', ' ') }} 定稿</span>
</div>
<div class="flex space-x-2.5">
<button v-if="adjustStats.adjustStatus" @click="unfinalizeAdjust"
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-rotate-ccw" /></svg>撤销
</button>
<button @click="saveAdjustDraft"
class="px-4 py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-bold active:scale-95 transition">存草稿</button>
<button @click="finalizeAdjust"
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>
<p class="text-center text-[10px] text-slate-400 mt-2.5">存草稿不改变发放金额;定稿后按终稿金额计算。日常打卡记录不受影响。</p>
</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>
<div id="boot-fail" hidden class="max-w-md mx-auto min-h-screen bg-slate-50 flex items-center justify-center p-8">
<div class="card p-6 text-center">
<p class="text-sm font-bold text-slate-800">页面没能加载完成</p>
<p class="text-xs text-slate-500 mt-2 leading-relaxed">缺少 <code class="font-mono-num">vendor/vue.global.prod.js</code>。<br />你的打卡数据还在本机,没有丢。<br />刷新重试,或检查部署时 vendor/ 目录是否一起拷了。</p>
</div>
</div>
<!-- 离线优先的同步引擎:不依赖 Vue,独立于页面逻辑,方便以后套 APP 壳复用 -->
<script src="sync.js"></script>
<script>
// Vue 没加载成功时,v-cloak 会把整个 #app 遮成白屏。
// 白屏比闪一屏 {{ }} 强,但用户仍然不知道发生了什么,所以给一个明确提示。
if (typeof Vue === 'undefined') {
document.getElementById('app').remove();
document.getElementById('boot-fail').hidden = false;
throw new Error('Vue 未加载,vendor/vue.global.prod.js 缺失或被拦');
}
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, // 夜班默认工时
defaultShift: 'day' // 打卡默认班次:'day' 白班 / 'night' 夜班
};
const settings = reactive({ ...DEFAULTS });
const records = ref({});
// 设置字段的中文名:登录合并冲突对照时要给人看,不能直接抱 key
const SETTING_LABELS = {
rate: '基础时薪', fullDays: '满勤门槛(天)', fullBonusPerHour: '满勤奖单价',
foundBonus: '查获奖励/件', missedPenalty: '漏查处罚/件', cutoff: '上半月截止日',
payDayA: '发薪日 A', payDayB: '发薪日 B', dayHours: '白班默认工时',
nightHours: '夜班默认工时', defaultShift: '默认班次'
};
// 月度奖惩终稿:key = 'YYYY-MM'
// { status:'draft'|'final', finalAmount:Number|null, note:String, finalizedAt:ISOString }
// 次月 1–2 号汇总申报后,实际审核金额可能与日常累计不同(如累计 100、审核只认 80)。
// 定稿后所有发放口径都以终稿为准,日常每日记录通道保持不变、也不锁定。
const monthAdjust = 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)))),
// payDayA/payDayB 原本只用于文案展示,现在参与日历发薪日判定,必须进 cfg 做同样的洗值,
// 否则 payDayKind() 拿到 undefined,比较永远不命中,角标与内描边都不会出现。
payDayA: Math.min(31, Math.max(1, Math.round(sn(settings.payDayA, DEFAULTS.payDayA)))),
payDayB: Math.min(31, Math.max(1, Math.round(sn(settings.payDayB, DEFAULTS.payDayB)))),
dayHours: clampHours(sn(settings.dayHours, DEFAULTS.dayHours), DEFAULTS.dayHours),
nightHours: clampHours(sn(settings.nightHours, DEFAULTS.nightHours), DEFAULTS.nightHours),
defaultShift: settings.defaultShift === 'night' ? 'night' : 'day'
}));
// ===== 页签 =====
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 legendOpen = ref(false);
const settingsOpen = 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(cfg.value.defaultShift));
// ===== 日期辅助 =====
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 monthKeyOf = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}`;
const getMonthAdjust = (y, m) => monthAdjust.value[monthKeyOf(y, m)] || null;
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 rawReview = foundBonus - missedPenalty - otherPenalties;
// 与「满勤未达标 → 满勤奖按 0 计」同一个道理:
// 奖惩未定稿 → 一律按 0 计,不进任何应得/发放口径;只有定稿后才用审核终稿金额。
const adj = getMonthAdjust(year, month);
const finalized = !!(adj && adj.status === 'final' && Number.isFinite(Number(adj.finalAmount)));
const reviewAmount = finalized ? Number(adj.finalAmount) : 0;
const reviewDelta = finalized ? reviewAmount - rawReview : 0;
// 计入应得的奖惩净额:未定稿恒为 0,页面单列这一项并标明「未定稿不计」。
const reviewNet = reviewAmount;
// 发放净额 = 满勤奖 + 已定稿奖惩。次月 payDayB 这两笔与下半月工时一起发,
// 「挣得」与「结转」两套口径都复用它,保证同名同数。
const adjustNet = fullBonus + reviewNet;
return {
h1, h2, totalHours, workDays, dayShifts, nightShifts, restDays, leaveDays,
foundCount, missedCount, otherPenalties,
fullBonus, foundBonus, missedPenalty, adjustNet, reviewNet,
rawReview, reviewAmount, reviewDelta, finalized,
adjustStatus: adj ? adj.status : null,
adjustNote: adj && adj.note ? adj.note : '',
finalizedAt: adj && adj.finalizedAt ? adj.finalizedAt : '',
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));
// ===== 两套记账口径(这是页面之前最主要的歧义源) =====
// 归属制「挣得」:本月 1–月底干出来的钱,不管什么时候发。打一天卡当天就涨。
// 只含工时 + 已确认奖惩(满勤奖月底才确定,不混入挣得,单独展示)。
// 收付制「进账」:本月卡里实际进的钱,含上月结转部分。
// 两者永远不相等,必须各自具名、分区展示,不能都叫「本月」。
// 决策1:统一「X月挣得」= 本月劳动产生的全部价值(不管哪天发)。
// = 工时收入 + 满勤奖(未达 27 天为 0)+ 奖惩净额(未定稿取日常累计,已定稿取终稿)
// adjustNet 本身 = fullBonus + reviewAmount,正好是「满勤奖 + 奖惩净额」。
// 打卡页大字与汇总页「合计应得」共用这一个值,不再有两个不同的「挣得」。
const earnedTotal = computed(() => monthStats.value.basePay + monthStats.value.adjustNet);
const cashTotal = computed(() => payoutThisB.value + payoutThisA.value);
// 「挣得」拆成两笔落地时间:上半月工时随本月 payDayA;下半月工时 + 奖惩结转次月 payDayB。
// 这一行是两套口径之间唯一的桥,解释了为何挣得 ≠ 进账。
// 注意:结转那笔只用「已确认的奖惩」,满勤奖月底才确定,不计入挣得(另作预估展示)。
// 决策2:结转 = 次月 payDayB 实际到账那一笔,必须含满勤奖(业务规则:满勤奖压到次月 10 号发)。
// 直接复用 payoutNextB,保证「结转」与「次月实际上账」永远同一个数,不再出现账目缺口。
const earnedCarry = computed(() => payoutNextB.value);
const earnedSplit = computed(() => ({ paid: payoutThisA.value, carry: earnedCarry.value }));
// 上半月那笔到没到账,只在看当前月份时才有「待发」说法;翻历史月份一律算已发。
const payDayAPassed = computed(() => {
if (!isCurrentMonth.value) {
const n = new Date();
return currentYear.value < n.getFullYear() ||
(currentYear.value === n.getFullYear() && currentMonth.value < n.getMonth());
}
return new Date().getDate() >= cfg.value.payDayA;
});
// 处罚吃穿下半月工时时,结转那笔被 ¥0 兰住,于是 paid+carry > earnedTotal。页面需要明说。
const earnedFloored = computed(() => monthStats.value.h2 * cfg.value.rate + monthStats.value.adjustNet < 0);
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);
// 看板主数字 = 统一口径的「X月挣得」,与汇总页「合计应得」严格同源。
// 明细行三项(工时收入 + 满勤奖 + 奖惩净额)相加必须严丝合缝等于这个大字。
const boardMain = computed(() => moneyParts(earnedTotal.value));
// 距满勤还差几天(达标后为 0),供顶部进度胶囊使用
const fullDaysLeft = computed(() => Math.max(0, cfg.value.fullDays - monthStats.value.workDays));
const hasPenalty = (day) => {
const r = getRecord(day);
return !!r && r.status === 'work' && (Number(r.contraband_missed || 0) > 0 || Number(r.other_penalty || 0) > 0);
};
// 发薪日判定:'a' = 当月 payDayA(上半月工时,线上);'b' = 当月 payDayB(上月下半月工时 + 上月奖惩,线下)
// 只做统计标注,不处理"遇周末顺延",实际到账日以单位为准。
const payDayKind = (day) => {
const c = cfg.value;
if (day === c.payDayA) return 'a';
if (day === c.payDayB) return 'b';
return null;
};
// 选中日如果是发薪日,打卡抽屉顶部展示该笔到账(不打断打卡主流程)
const selectedPayInfo = computed(() => {
const kind = payDayKind(selectedDay.value);
if (!kind) return null;
if (kind === 'a') {
return {
kind: 'a',
amount: payoutThisA.value,
desc: `本月上半月 ${fmt(monthStats.value.h1)}h · 纯工时`,
finalized: true
};
}
return {
kind: 'b',
amount: payoutThisB.value,
desc: `${prevLabel.value.month + 1}月下半月 ${fmt(prevStats.value.h2)}h + 奖惩 ${signed(prevStats.value.adjustNet)}`,
finalized: prevStats.value.finalized
};
});
// 上下半月分界格: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');
// 今天的 ring 与发薪日内描边都是 box-shadow,会互相覆盖,
// 所以重合时只保留今天的 ring,发薪日仍由右上角 ¥ 角标表达。
if (isToday(day)) cls.push('ring-2 ring-indigo-500/70');
else {
const pk = payDayKind(day);
if (pk) cls.push(pk === 'a' ? 'pay-ring-a' : 'pay-ring-b');
}
if (!r) cls.push('bg-white border-slate-100');
else if (r.status === 'work') cls.push(r.shift_type === 'night' ? 'bg-indigo-50/70 border-indigo-100' : 'bg-amber-50/70 border-amber-100');
else if (r.status === 'rest') cls.push('bg-slate-100/70 border-slate-200');
else if (r.status === 'leave') cls.push('bg-sky-50/60 border-sky-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(cfg.value.defaultShift);
isDrawerOpen.value = true;
};
const openSettings = () => { settingsOpen.value = true; };
const closeSettings = () => { settingsOpen.value = false; };
// ===== 奖惩终稿修正入口 =====
// 日常每日记录通道不受影响;这里只覆盖"待申报奖惩总金额"。
// 场景:累计 100,次月初审核下来只认 80 → 定稿为 80。
const adjustOpen = ref(false);
const adjustTarget = ref({ year: today.getFullYear(), month: today.getMonth() });
const adjustDraft = ref({ finalAmount: null, note: '' });
const adjustStats = computed(() => aggregateMonth(adjustTarget.value.year, adjustTarget.value.month));
const adjustDelta = computed(() => {
const v = Number(adjustDraft.value.finalAmount);
if (!Number.isFinite(v)) return null;
return v - adjustStats.value.rawReview;
});
const persistAdjust = (dirtyMonths) => {
localStorage.setItem('work_month_adjust', JSON.stringify(monthAdjust.value));
if (!suppressSync && window.WHTSync && dirtyMonths) {
const arr = Array.isArray(dirtyMonths) ? dirtyMonths : [dirtyMonths];
window.WHTSync.markManyDirty({ adjust: arr });
}
};
const openAdjust = (year, month) => {
adjustTarget.value = { year, month };
const cur = getMonthAdjust(year, month);
const raw = aggregateMonth(year, month).rawReview;
adjustDraft.value = {
// 首次打开预填日常累计值,方便在它基础上改
finalAmount: cur && Number.isFinite(Number(cur.finalAmount)) ? Number(cur.finalAmount) : Math.round(raw * 10) / 10,
note: cur && cur.note ? cur.note : ''
};
adjustOpen.value = true;
};
const closeAdjust = () => { adjustOpen.value = false; };
const writeAdjust = (status) => {
const t = adjustTarget.value;
const v = Number(adjustDraft.value.finalAmount);
if (!Number.isFinite(v)) { showToast('请先填写终稿金额'); return false; }
const mk = monthKeyOf(t.year, t.month);
monthAdjust.value[mk] = {
status,
finalAmount: v,
note: String(adjustDraft.value.note || '').slice(0, 60),
finalizedAt: status === 'final' ? new Date().toISOString() : ''
};
persistAdjust(mk);
return true;
};
const saveAdjustDraft = () => {
if (!writeAdjust('draft')) return;
adjustOpen.value = false;
showToast('已存为草稿,尚未生效');
};
const finalizeAdjust = () => {
if (!writeAdjust('final')) return;
adjustOpen.value = false;
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(30);
showToast(`已定稿 ${adjustTarget.value.month + 1}月奖惩`);
};
const unfinalizeAdjust = () => {
const t = adjustTarget.value;
const mk = monthKeyOf(t.year, t.month);
delete monthAdjust.value[mk];
persistAdjust(mk);
adjustOpen.value = false;
showToast('已撤销定稿,恢复日常累计值');
};
const selectShift = (type) => {
draftRecord.value.shift_type = type;
draftRecord.value.hours = type === 'night' ? cfg.value.nightHours : cfg.value.dayHours;
};
// suppressSync:远端数据写回本地时用,避免「拉下来 → 又当成本地改动推回去」的回环
let suppressSync = false;
const persist = (dirtyDays) => {
localStorage.setItem('work_records', JSON.stringify(records.value));
if (!suppressSync && window.WHTSync && dirtyDays) {
const arr = Array.isArray(dirtyDays) ? dirtyDays : [dirtyDays];
window.WHTSync.markManyDirty({ records: arr });
}
};
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 = '';
}
const dayKey = keyOf(currentYear.value, currentMonth.value, selectedDay.value);
records.value[dayKey] = JSON.parse(JSON.stringify(draftRecord.value));
persist(dayKey);
isDrawerOpen.value = false;
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(30);
showToast('已保存 ' + (currentMonth.value + 1) + '月' + selectedDay.value + '日');
};
const deleteRecord = () => {
const dayKey = keyOf(currentYear.value, currentMonth.value, selectedDay.value);
delete records.value[dayKey];
persist(dayKey);
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)}`,
``,
`【${m}月总劳动应得】${money(earnedTotal.value)}`,
`工时收入 ${money(s.basePay)}(确定) | 满勤奖 ${money(s.fullBonus)}(${s.workDays >= settings.fullDays ? '已达标' : '未达' + settings.fullDays + '天不计'}) | 奖惩 ${signed(s.reviewNet)}(${s.finalized ? '已定稿' : '未定稿不计,仅记录 ' + signed(s.rawReview)})`,
`何时到手:${m}/${settings.payDayA} ${money(earnedSplit.value.paid)}(上半月工时) | ${nm}/${settings.payDayB} ${money(earnedSplit.value.carry)}(结转:下半月工时+奖惩+满勤奖)`,
``,
`【${m}月奖惩明细·次月${settings.payDayB}号结算】`,
`满勤奖 ${s.fullBonus > 0 ? '+' + money(s.fullBonus) : '¥0(未满勤)'}`,
`查获 +${money(s.foundBonus)}(${s.foundCount}件) | 漏查 -${money(s.missedPenalty)}(${s.missedCount}件) | 其他 -${money(s.otherPenalties)}`,
`日常累计(仅记录,未定稿不计入应得)${signed(s.rawReview)}${s.finalized ? ` → 审核终稿 ${signed(s.reviewAmount)}` : ''}`,
`计入应得的奖惩 ${signed(s.reviewNet)}(${s.finalized ? '已定稿' : '未定稿按 ¥0 计,同满勤未达标口径'})`,
``,
`【${m}月进账·实际到账】${money(cashTotal.value)}`,
`${m}月${settings.payDayB}日 ${money(payoutThisB.value)}(归属${pm}月:下半月 ${fmt(p.h2)}h + 奖惩 ${signed(p.adjustNet)})`,
`${m}月${settings.payDayA}日 ${money(payoutThisA.value)}(归属${m}月:上半月 ${fmt(s.h1)}h)`,
`合计 ${money(cashTotal.value)}(含${pm}月结转 ${money(payoutThisB.value)})`,
``,
`【${nm}月${settings.payDayB}日预估·结转】${money(earnedCarry.value)}(${m}月下半月 ${fmt(s.h2)}h + 满勤奖 ${money(s.fullBonus)} + 奖惩 ${signed(s.reviewNet)})`
].join('\n');
};
const copyReport = () => writeClipboard(buildReport(), () => {
copied.value = true;
setTimeout(() => copied.value = false, 2000);
});
const bundle = () => JSON.stringify({
app: 'work-hours-tracker',
version: 3,
exportedAt: new Date().toISOString(),
settings: { ...settings },
records: records.value,
monthAdjust: monthAdjust.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;
const dirtyDays = [], dirtyMonths = [];
for (const k in incoming) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(k)) continue;
records.value[k] = incoming[k];
dirtyDays.push(k);
n++;
}
if (parsed.settings) {
for (const k in DEFAULTS) {
if (parsed.settings[k] !== undefined) settings[k] = parsed.settings[k];
}
}
// 奖惩终稿(v3 开始导出):同月份覆盖,旧版备份没此字段时保持现有
if (parsed.monthAdjust && typeof parsed.monthAdjust === 'object') {
for (const k in parsed.monthAdjust) {
if (!/^\d{4}-\d{2}$/.test(k)) continue;
monthAdjust.value[k] = parsed.monthAdjust[k];
dirtyMonths.push(k);
}
persistAdjust(dirtyMonths);
}
persist(dirtyDays);
if (!suppressSync && window.WHTSync && parsed.settings) window.WHTSync.markDirty('settings');
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);
const removed = [];
for (const k in records.value) if (k.indexOf(prefix) === 0) { delete records.value[k]; removed.push(k); }
persist(removed);
showToast(`已清空 ${label} ${removed.length} 条记录`);
};
const clearAll = () => {
if (!confirm('确定清空全部打卡记录?建议先导出备份。此操作不可撤销。')) return;
if (!confirm('再次确认:所有月份的记录都会被删除。')) return;
const removed = Object.keys(records.value);
records.value = {};
persist(removed);
showToast('已清空全部记录');
};
// ===== 云端同步(离线优先:本地 localStorage 仍是唯一真相,同步只是额外把数据推到服务器)=====
const sync = reactive({
status: 'idle', user: null, pending: 0, lastSyncAt: null, lastError: '',
serverUsers: null, apiBase: '', busy: false
});
const syncPanelOpen = ref(false);
const loginForm = ref({ username: '', password: '' });
// 首次登录合并:本机与服务器同一天内容不同时,让用户选哪份赢
const mergeOpen = ref(false);
const mergeInfo = ref(null);
/** 把一条记录压成一行人能读的短描述,用于冲突对照 */
const describeRecord = (r) => {
if (!r) return '(无)';
if (r.status === 'rest') return '休息';
if (r.status === 'leave') return '请假';
const bits = [(r.shift_type === 'night' ? '夜班' : '白班') + ' ' + fmt(r.hours) + 'h'];
if (r.contraband_found > 0) bits.push('查获' + r.contraband_found);
if (r.contraband_missed > 0) bits.push('漏查' + r.contraband_missed);
if (Number(r.other_penalty) > 0) bits.push('扣' + money(r.other_penalty));
return bits.join(' · ');
};
const describeAdjust = (a) => {
if (!a) return '(无)';
const st = a.status === 'final' ? '已定稿' : '草稿';
const amt = (a.finalAmount === null || a.finalAmount === undefined || a.finalAmount === '')
? '未填金额' : signed(a.finalAmount);
return st + ' ' + amt;
};
const mergeRows = computed(() => {
const d = mergeInfo.value && mergeInfo.value.diff;
if (!d) return [];
const rows = [];
for (const c of d.conflictDetail || []) {
rows.push({ key: c.key, label: c.key.slice(5).replace('-', '月') + '日',
local: describeRecord(c.local), remote: describeRecord(c.remote) });
}
for (const c of d.adjustConflictDetail || []) {
rows.push({ key: c.key, label: c.key.slice(5) + '月奖惩',
local: describeAdjust(c.local), remote: describeAdjust(c.remote) });
}
for (const s of d.settingsDiff || []) {
rows.push({ key: 'set:' + s.key, label: SETTING_LABELS[s.key] || ('设置 ' + s.key),
local: String(s.local), remote: String(s.remote) });
}
return rows;
});
const syncStatusText = computed(() => ({
idle: '未登录·仅存本机',
ready: sync.pending > 0 ? `待上传 ${sync.pending} 项` : '已同步',
syncing: '同步中…',
offline: '连不上服务器·本地照常用',
error: '同步出错'
})[sync.status] || sync.status);
const syncStatusClass = computed(() => ({
idle: 'text-slate-500 bg-slate-100',
ready: sync.pending > 0 ? 'text-amber-700 bg-amber-100' : 'text-emerald-700 bg-emerald-100',
syncing: 'text-indigo-700 bg-indigo-100',
offline: 'text-amber-700 bg-amber-100',
error: 'text-rose-700 bg-rose-100'
})[sync.status] || 'text-slate-500 bg-slate-100');
const syncTimeText = computed(() => {
if (!sync.lastSyncAt) return '';
const d = new Date(sync.lastSyncAt);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
});
/** 把远端变更写进本地。suppressSync 全程打开,否则会把刚拉下来的数据当成本地改动推回去。 */
function applyRemoteData({ records: rs, adjust: as, settings: st, replace }) {
suppressSync = true;
try {
if (replace) { records.value = {}; monthAdjust.value = {}; }
for (const [day, val] of Object.entries(rs || {})) {
if (val === null) delete records.value[day];
else records.value[day] = val;
}
for (const [m, val] of Object.entries(as || {})) {
if (val === null) delete monthAdjust.value[m];
else monthAdjust.value[m] = val;
}
if (st) for (const k in DEFAULTS) if (st[k] !== undefined) settings[k] = st[k];
persist();
persistAdjust();
} finally {
// 设置的 watch 是异步触发的,得等它跑完再恢复,否则仍会被标脏
setTimeout(() => { suppressSync = false; }, 0);
}
}
const withBusy = async (fn) => {
sync.busy = true;
try { return await fn(); }
catch (e) { showToast(e.message || '操作失败'); return null; }
finally { sync.busy = false; }
};
const doLogin = () => withBusy(async () => {
const { username, password } = loginForm.value;
if (!username || !password) { showToast('请填写用户名与密码'); return; }
await window.WHTSync.login(username.trim(), password);
loginForm.value.password = '';
// 只读探一份服务端快照:不写本地、不动 dirty、不改 rev。
// 这里不能直接 pullAll —— 它的语义是「用服务器覆盖本机」,
// 本机还没推上去的天数会无声消失(实测过,会丢)。
const dump = await window.WHTSync.peekRemote();
const diff = window.WHTSync.diffLocal(dump);
const remoteDays = Object.keys(dump.records || {}).length;
const localDays = Object.keys(records.value).length;
const localAdj = Object.keys(monthAdjust.value).length;
// 本机干净 → 没有可丢的东西,直接拉全量
if (localDays === 0 && localAdj === 0) {
await window.WHTSync.pullAll();
showToast(remoteDays > 0 ? `已登录,拉取 ${remoteDays} 天记录` : '已登录,服务器暂无数据');
return;
}
// 同一天两边内容不同 → 必须让用户决定,不替他选
if (diff.conflict.length || diff.adjustConflict.length || diff.settingsDiff.length) {
mergeInfo.value = { diff, remoteDays, localDays };
mergeOpen.value = true;
return;
}
// 无冲突 → 双向合并(本地全标脏后 push,再 pull 远端独有),两边取并集
await runMerge(diff, 'local');
});
/**
* 执行合并。
* 机制:把本机数据标脏 → syncNow 先 push 后 pull(拉取起点是 push 之前的 rev)。
* winner='local' 冲突键也推上去 → 服务端被本机值覆盖
* winner='remote' 冲突键不标脏 → 拉取时远端值落地本机
* 非冲突数据两边都不会丢:本机独有的被推上去,服务器独有的被拉下来。
*/
async function runMerge(diff, winner) {
// 合并前留一份本机快照。远端值落地是不可逆的,出意外至少数据还在。
window.WHTSync.snapshotLocal('login-merge-' + winner);
const excludeRecords = winner === 'remote' ? diff.conflict : [];
const excludeAdjust = winner === 'remote' ? diff.adjustConflict : [];
// 远端已有设置且用户选了服务器版本时,别把本机默认值推上去盖掉远端
const pushSettings = !diff.remoteHasSettings || winner === 'local';
const marked = window.WHTSync.markAllLocalDirty({ settings: pushSettings, excludeRecords, excludeAdjust });
const r = await window.WHTSync.syncNow();
if (!r.ok) {
showToast('合并失败:' + (r.error || r.reason) + '(本机数据未动)');
return r;
}
const total = Object.keys(records.value).length;
const parts = [`已合并,共 ${total} 天`];
if (marked.records) parts.push(`上传 ${marked.records}`);
if (r.applied) parts.push(`拉取 ${r.applied}`);
showToast(parts.join(' · '));
return r;
}
const resolveMerge = (winner) => withBusy(async () => {
const info = mergeInfo.value;
if (!info) return;
mergeOpen.value = false;
await runMerge(info.diff, winner);
mergeInfo.value = null;
});
// 先不合并:保持登录但不动任何数据。同步面板会留一个入口重新进来。
const deferMerge = () => {
mergeOpen.value = false;
showToast('已跳过合并,本机数据未改动');
};
const reopenMerge = () => withBusy(async () => {
const dump = await window.WHTSync.peekRemote();
const diff = window.WHTSync.diffLocal(dump);
if (!diff.conflict.length && !diff.adjustConflict.length && !diff.settingsDiff.length) {
await runMerge(diff, 'local');
return;
}
mergeInfo.value = {
diff,
remoteDays: Object.keys(dump.records || {}).length,
localDays: Object.keys(records.value).length
};
mergeOpen.value = true;
});
const doRegister = () => withBusy(async () => {
const { username, password } = loginForm.value;
if (!username || !password) { showToast('请填写用户名与密码'); return; }
if (password.length < 8) { showToast('密码至少 8 位'); return; }
await window.WHTSync.register(username.trim(), password);
showToast('账号已创建,正在登录…');
await doLogin();
});
const doLogout = () => withBusy(async () => {
if (sync.pending > 0 && !confirm(`还有 ${sync.pending} 项未上传,确定退出?本地数据不会删除。`)) return;
await window.WHTSync.logout({ keepLocal: true });
showToast('已退出登录,本地数据保留');
});
const doSyncNow = () => withBusy(async () => {
const r = await window.WHTSync.syncNow();
showToast(r.ok ? (r.applied > 0 ? `已同步,拉取 ${r.applied} 项变更` : '已是最新') : ('同步失败:' + (r.error || r.reason)));
});
const doPullAll = () => withBusy(async () => {
if (!confirm('用服务器数据覆盖本机?本机未上传的改动会丢失。')) return;
const d = await window.WHTSync.pullAll();
showToast(`已从服务器拉取 ${Object.keys(d.records || {}).length} 天记录`);
});
const doPushAll = () => withBusy(async () => {
const r = await window.WHTSync.pushAll();
showToast(`已上传本地全部数据(版本 ${r.rev})`);
});
const saveApiBase = () => {
window.WHTSync.setApiBase(sync.apiBase);
showToast(sync.apiBase ? '已保存服务器地址' : '已恢复为同源');
window.WHTSync.health().catch(() => {});
};
// ===== 载入与持久化 =====
onMounted(() => {
const savedRecords = localStorage.getItem('work_records');
if (savedRecords) { try { records.value = JSON.parse(savedRecords) || {}; } catch (e) {} }
const savedAdjust = localStorage.getItem('work_month_adjust');
if (savedAdjust) { try { monthAdjust.value = JSON.parse(savedAdjust) || {}; } 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) {}
}
if (window.WHTSync) {
window.WHTSync.setApplyHook(applyRemoteData);
window.WHTSync.onChange(s => {
sync.status = s.status;
sync.user = s.user;
sync.pending = s.pending;
sync.lastSyncAt = s.lastSyncAt;
sync.lastError = s.lastError;
sync.serverUsers = s.serverUsers;
});
sync.apiBase = window.WHTSync.getApiBase() === location.origin ? '' : window.WHTSync.getApiBase();
// 探一下服务端在不在、有没有账号,决定设置页该显示「创建账号」还是「登录」
window.WHTSync.health().catch(() => {});
}
});
watch(settings, () => {
localStorage.setItem('work_settings', JSON.stringify({ ...settings }));
if (!suppressSync && window.WHTSync) window.WHTSync.markDirty('settings');
}, { deep: true });
return {
settings, cfg, records, recordCount,
tab, tabs, statusOptions,
currentYear, currentMonth, selectedDay, daysInMonth, firstDayOfWeek,
isDrawerOpen, settingsOpen, legendOpen, draftRecord, hourOptions,
HOUR_MIN, HOUR_MAX, HOUR_STEP, hInt, hFrac, clampHours,
copied, dataCopied, toast,
monthStats, prevStats, prevLabel, nextLabel,
payoutThisA, payoutThisB, payoutNextB, penaltyDetails,
earnedTotal, cashTotal, earnedSplit, earnedFloored, payDayAPassed,
fullDaysLeft,
fmt, money, signed, moneyParts, boardMain, buildReport, applyImport,
isToday, isCurrentMonth, getRecord, hasPenalty, cellClass, isCutEdge,
payDayKind, selectedPayInfo,
monthAdjust, adjustOpen, adjustTarget, adjustDraft, adjustStats, adjustDelta,
openAdjust, closeAdjust, saveAdjustDraft, finalizeAdjust, unfinalizeAdjust,
changeMonth, goToday, openDayDrawer, selectShift,
openSettings, closeSettings,
sync, syncPanelOpen, loginForm, syncStatusText, syncStatusClass, syncTimeText,
mergeOpen, mergeInfo, mergeRows, resolveMerge, deferMerge, reopenMerge,
doLogin, doRegister, doLogout, doSyncNow, doPullAll, doPushAll, saveApiBase,
saveRecord, deleteRecord, copyReport,
exportData, copyData, importData, resetSettings, clearMonth, clearAll
};
}
}).mount('#app');
</script>
</body>
</html>