Files
work-hours-tracker/index.html
T
2026-09-15 05:25:58 +08:00

2359 lines
157 KiB
HTML
Raw Permalink 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>
<meta name="theme-color" content="#312E81" />
<!-- PWA 可安装三件套:manifest / iOS 专属图标 / SW 注册(见 setup 末尾 boot 钩子)。
只在 HTTPS(或 localhost)安全上下文生效,HTTP 裸访问自动降级为普通网页,不影响现有行为。 -->
<link rel="manifest" href="manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="工时薪酬" />
<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 v-if="tab !== 'settings'" class="flex justify-between items-center">
<div class="flex items-center space-x-1">
<button @click="changeMonth(-1)" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center"><svg class="ico text-base"><use href="#i-chevron-left" /></svg></button>
<span class="text-base font-bold tracking-wide font-mono-num">{{ currentYear }}年{{ currentMonth + 1 }}月</span>
<button @click="changeMonth(1)" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center"><svg class="ico text-base"><use href="#i-chevron-right" /></svg></button>
</div>
<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-else class="flex items-center h-7">
<span class="text-base font-bold tracking-wide flex items-center gap-1.5"><svg class="ico text-[17px]"><use href="#i-settings" /></svg>系统设置</span>
</div>
<!-- 主数字:本月「挣得」(仅日历页)。¥ 与小数位缩小,整数部放大,形成阶梯感。
注意:这里故意用归属制(本月干出来的)而不是现金流。日历页的核心动作是打卡,
用归属制打一天卡数字当场就涨;用现金流则毫无反馈(影响的是下一个发薪日)。 -->
<div v-if="tab === 'calendar'" class="mt-4">
<!-- 第一层:本月总劳动应得(归属制唯一口径,与汇总页「合计应得」同源) -->
<p class="text-[11px] text-indigo-100/70 font-medium flex items-center gap-1.5">
<svg class="ico text-[13px]"><use href="#i-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>
</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>
<p v-if="monthStats.totalHours > 0" class="text-slate-600">
综合时薪 <b class="text-indigo-600 font-mono-num font-bold">¥{{ effectiveHourly }}</b> /h
<span class="text-slate-400">(合计应得 {{ money(earnedTotal) }} ÷ 总工时 {{ fmt(monthStats.totalHours) }}h,含满勤奖与审核定稿)</span>
</p>
</template>
</div>
</div>
<button @click="copyReport" class="w-full py-3 bg-slate-800 text-white rounded-2xl text-xs font-bold active:scale-95 transition shadow-sm flex items-center justify-center gap-1.5">
<svg class="ico text-[15px]"><use :href="copied ? '#i-check' : '#i-copy'" /></svg>{{ copied ? '已复制到剪贴板' : '复制文本报告' }}
</button>
</section>
<!-- ========== ③ 设置 ========== -->
<section v-show="tab === 'settings'" class="px-4 pt-5 pb-4 space-y-3.5">
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-coins" /></svg>薪酬规则</p>
<div class="divide-y divide-slate-100">
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">基础时薪(元/h)</span>
<input type="number" step="0.5" min="0" v-model.number="settings.rate" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">满勤天数(天)</span>
<input type="number" step="1" min="1" max="31" v-model.number="settings.fullDays" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">满勤奖(元/工时)</span>
<input type="number" step="0.5" min="0" v-model.number="settings.fullBonusPerHour" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
</div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">满勤奖按「当月总工时 × 单价」计算,属于奖励,不并入当月工资。</p>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-shield-alert" /></svg>奖惩标准</p>
<div class="divide-y divide-slate-100">
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">查获违禁品(元/件)</span>
<input type="number" step="1" min="0" v-model.number="settings.foundBonus" class="w-24 text-right text-xs font-bold text-emerald-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">漏查违禁品(元/件)</span>
<input type="number" step="1" min="0" v-model.number="settings.missedPenalty" class="w-24 text-right text-xs font-bold text-rose-600 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
</div>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-calendar-clock" /></svg>发放周期</p>
<div class="divide-y divide-slate-100">
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">上半月截止日</span>
<input type="number" step="1" min="1" max="28" v-model.number="settings.cutoff" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">上半月发放日(当月)</span>
<input type="number" step="1" min="1" max="31" v-model.number="settings.payDayA" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">下半月发放日(次月)</span>
<input type="number" step="1" min="1" max="31" v-model.number="settings.payDayB" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
</div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
1–{{ settings.cutoff }}日工时 → 当月{{ settings.payDayA }}号发;{{ settings.cutoff + 1 }}日–月底工时 + 当月奖惩 → 次月{{ settings.payDayB }}号一起发。
</p>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-hourglass" /></svg>班次默认工时</p>
<div class="divide-y divide-slate-100">
<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="reopenMerge" :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-shield-alert" /></svg>比对本机与服务器(有冲突时可选)</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 pt-3 pb-1">
<div class="flex rounded-xl bg-slate-100 p-1">
<button @click="authMode = 'login'" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition"
:class="authMode === 'login' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'">账号登录</button>
<button @click="authMode = 'register'" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition"
:class="authMode === 'register' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'">注册新账号</button>
</div>
</div>
<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="authMode === 'register' ? '设置密码(至少 8 位)' : '输入密码(至少 8 位)'"
@keyup.enter="authMode === 'register' ? doRegister() : 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="pt-0.5">
<button v-if="authMode === 'login'" @click="doLogin" :disabled="sync.busy" class="w-full py-2.5 rounded-xl text-xs font-bold bg-indigo-600 text-white active:scale-95 transition disabled:opacity-40">立即登录</button>
<button v-else @click="doRegister" :disabled="sync.busy" class="w-full py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white active:scale-95 transition disabled:opacity-40">{{ sync.serverUsers === 0 ? '创建首个账号(管理员)' : '创建账号并登录' }}</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-if="sync.allowRegister">服务端已开放多用户注册,填写用户名密码即可直接创建账号。</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>
<!-- AI 接入 (MCP):让对话式 AI 只读查询打卡与薪酬;开关与令牌存服务器 DB,改动即时生效 -->
<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-lightbulb" /></svg>AI 接入 (MCP)</p>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="mcpBadge.cls">{{ mcpBadge.text }}</span>
</div>
<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>
<button @click="toggleMcp" :disabled="mcp.busy || !mcp.configured" class="relative w-11 h-6 rounded-full transition shrink-0 disabled:opacity-40" :class="mcp.enabled ? 'bg-emerald-500' : 'bg-slate-300'">
<span class="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" :class="mcp.enabled ? 'left-[22px]' : 'left-0.5'"></span>
</button>
</div>
<div v-if="mcp.configured" class="flex items-center justify-between gap-2 px-5 py-3">
<span class="text-xs text-slate-600 shrink-0">访问令牌</span>
<span class="text-[11px] font-bold text-slate-700 font-mono-num">{{ mcp.masked }}</span>
</div>
<button v-if="mcp.configured" @click="copyMcpToken" :disabled="mcp.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="mcpCopied ? '#i-check' : '#i-copy'" /></svg>{{ mcpCopied ? '已复制,去 AI 客户端粘贴' : '复制访问令牌(明文)' }}</button>
<button @click="newMcpToken" :disabled="mcp.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]" :class="{ 'animate-spin': mcp.busy }"><use href="#i-refresh-cw" /></svg>{{ mcp.configured ? '重置令牌(旧令牌立即失效)' : '生成访问令牌并开启' }}</button>
</div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
端点 POST /mcp(Bearer 令牌鉴权),只读:AI 只能查询出勤、工时与薪酬,改不了任何数据。开关与令牌改动即时生效,无需重启;重置后旧客户端立即失效。
</p>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-database" /></svg>数据</p>
<div class="divide-y divide-slate-100">
<div class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">已存打卡记录</span>
<span class="text-xs font-bold text-slate-800 font-mono-num">{{ recordCount }} 天</span>
</div>
<button @click="exportData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-download" /></svg>导出备份(JSON 文件)</button>
<button @click="copyData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use :href="dataCopied ? '#i-check' : '#i-copy'" /></svg>{{ dataCopied ? '已复制到剪贴板' : '复制数据到剪贴板' }}</button>
<label class="flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition cursor-pointer">
<svg class="ico text-[15px]"><use href="#i-upload" /></svg>导入备份(同日期覆盖)
<input type="file" accept=".json,application/json" @change="importData" class="hidden" />
</label>
<button @click="resetSettings" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-slate-500 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-rotate-ccw" /></svg>恢复默认设置(不动打卡数据)</button>
<button @click="clearMonth" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-trash-2" /></svg>清空 {{ currentYear }}年{{ currentMonth + 1 }}月记录</button>
<button @click="clearAll" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-triangle-alert" /></svg>清空全部打卡记录</button>
</div>
</div>
<p class="text-center text-[10px] text-slate-400 leading-relaxed pt-1 pb-2">
<template v-if="sync.user">数据存本机浏览器,并同步到服务器账号 <b class="text-slate-500">{{ sync.user.username }}</b>。<br />换设备登录同一账号即可看到同一份数据。</template>
<template v-else>数据仅保存在本机浏览器(localStorage)。<br />清除浏览器数据会丢失,建议登录同步或定期导出备份。</template>
</p>
</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="reopenMerge" :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-shield-alert" /></svg>比对本机与服务器(有冲突时可选)</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 pt-3 pb-1">
<div class="flex rounded-xl bg-slate-100 p-1">
<button @click="authMode = 'login'" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition"
:class="authMode === 'login' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'">账号登录</button>
<button @click="authMode = 'register'" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition"
:class="authMode === 'register' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'">注册新账号</button>
</div>
</div>
<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="authMode === 'register' ? '设置密码(至少 8 位)' : '输入密码(至少 8 位)'"
@keyup.enter="authMode === 'register' ? doRegister() : 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="pt-0.5">
<button v-if="authMode === 'login'" @click="doLogin" :disabled="sync.busy" class="w-full py-2.5 rounded-xl text-xs font-bold bg-indigo-600 text-white active:scale-95 transition disabled:opacity-40">立即登录</button>
<button v-else @click="doRegister" :disabled="sync.busy" class="w-full py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white active:scale-95 transition disabled:opacity-40">{{ sync.serverUsers === 0 ? '创建首个账号(管理员)' : '创建账号并登录' }}</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-if="sync.allowRegister">服务端已开放多用户注册,填写用户名密码即可直接创建账号。</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>
<!-- AI 接入 (MCP):让对话式 AI 只读查询打卡与薪酬;开关与令牌存服务器 DB,改动即时生效 -->
<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-lightbulb" /></svg>AI 接入 (MCP)</p>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="mcpBadge.cls">{{ mcpBadge.text }}</span>
</div>
<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>
<button @click="toggleMcp" :disabled="mcp.busy || !mcp.configured" class="relative w-11 h-6 rounded-full transition shrink-0 disabled:opacity-40" :class="mcp.enabled ? 'bg-emerald-500' : 'bg-slate-300'">
<span class="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" :class="mcp.enabled ? 'left-[22px]' : 'left-0.5'"></span>
</button>
</div>
<div v-if="mcp.configured" class="flex items-center justify-between gap-2 px-5 py-3">
<span class="text-xs text-slate-600 shrink-0">访问令牌</span>
<span class="text-[11px] font-bold text-slate-700 font-mono-num">{{ mcp.masked }}</span>
</div>
<button v-if="mcp.configured" @click="copyMcpToken" :disabled="mcp.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="mcpCopied ? '#i-check' : '#i-copy'" /></svg>{{ mcpCopied ? '已复制,去 AI 客户端粘贴' : '复制访问令牌(明文)' }}</button>
<button @click="newMcpToken" :disabled="mcp.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]" :class="{ 'animate-spin': mcp.busy }"><use href="#i-refresh-cw" /></svg>{{ mcp.configured ? '重置令牌(旧令牌立即失效)' : '生成访问令牌并开启' }}</button>
</div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
端点 POST /mcp(Bearer 令牌鉴权),只读:AI 只能查询出勤、工时与薪酬,改不了任何数据。开关与令牌改动即时生效,无需重启;重置后旧客户端立即失效。
</p>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-database" /></svg>数据</p>
<div class="divide-y divide-slate-100">
<div class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">已存打卡记录</span>
<span class="text-xs font-bold text-slate-800 font-mono-num">{{ recordCount }} 天</span>
</div>
<button @click="exportData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-download" /></svg>导出备份(JSON 文件)</button>
<button @click="copyData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use :href="dataCopied ? '#i-check' : '#i-copy'" /></svg>{{ dataCopied ? '已复制到剪贴板' : '复制数据到剪贴板' }}</button>
<label class="flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition cursor-pointer">
<svg class="ico text-[15px]"><use href="#i-upload" /></svg>导入备份(同日期覆盖)
<input type="file" accept=".json,application/json" @change="importData" class="hidden" />
</label>
<button @click="resetSettings" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-slate-500 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-rotate-ccw" /></svg>恢复默认设置(不动打卡数据)</button>
<button @click="clearMonth" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-trash-2" /></svg>清空 {{ currentYear }}年{{ currentMonth + 1 }}月记录</button>
<button @click="clearAll" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-triangle-alert" /></svg>清空全部打卡记录</button>
</div>
</div>
<p class="text-center text-[10px] text-slate-400 leading-relaxed pt-1 pb-2">
<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"
@click="tab = t.id"
class="flex-1 flex flex-col items-center pt-2 pb-2.5 active:scale-95 transition relative">
<!-- 选中态指示条:品牌色,贴在顶边 -->
<span class="absolute top-0 h-[3px] rounded-b-full bg-indigo-600 transition-all duration-300"
:class="tab === t.id ? 'w-8 opacity-100' : 'w-0 opacity-0'"></span>
<svg class="ico text-[21px] transition-colors" :class="tab === t.id ? 'text-indigo-600' : 'text-slate-400'"><use :href="'#' + t.icon" /></svg>
<span class="text-[10px] mt-1 font-bold transition-colors" :class="tab === t.id ? 'text-indigo-600' : 'text-slate-400'">{{ t.label }}</span>
</button>
</nav>
<!-- ===== 打卡抽屉 ===== -->
<transition name="drawer">
<div v-if="isDrawerOpen" class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm" @click.self="isDrawerOpen = false">
<div class="w-full max-w-md bg-white rounded-t-3xl px-5 pt-5 pb-6 shadow-2xl overflow-y-auto max-h-[88vh] no-scrollbar">
<div class="flex justify-between items-start mb-4 pb-3 border-b border-slate-100">
<div>
<h3 class="text-base font-bold text-slate-800">{{ currentMonth + 1 }}月{{ selectedDay }}日 打卡</h3>
</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="drawer">
<div v-if="mergeOpen" class="fixed inset-0 z-[55] flex items-end justify-center bg-black/40 backdrop-blur-sm">
<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="mb-4 pb-3 border-b border-slate-100">
<h3 class="text-base font-bold text-slate-800 flex items-center gap-1.5">
<svg class="ico text-[18px] text-amber-500"><use href="#i-shield-alert" /></svg>本机与服务器有冲突
</h3>
<p class="text-[10px] text-slate-400 mt-1 leading-relaxed">
下面这{{ mergeRows.length }}项两边内容不一样,需要你决定保留哪份。<span class="text-slate-500 font-medium">其余数据两边都会保留,不会丢。</span>
</p>
</div>
<div class="inset bg-slate-50 border border-slate-200/70 p-3 mb-3 flex items-center justify-around text-center">
<div>
<p class="text-[10px] text-slate-400">本机</p>
<p class="text-sm font-bold text-slate-700 font-mono-num">{{ mergeInfo ? mergeInfo.localDays : 0 }} 天</p>
</div>
<svg class="ico text-base text-slate-300"><use href="#i-refresh-cw" /></svg>
<div>
<p class="text-[10px] text-slate-400">服务器</p>
<p class="text-sm font-bold text-slate-700 font-mono-num">{{ mergeInfo ? mergeInfo.remoteDays : 0 }} 天</p>
</div>
</div>
<div class="space-y-2 mb-4">
<div v-for="r in mergeRows" :key="r.key" class="inset border border-slate-200/70 p-3">
<p class="text-[11px] font-bold text-slate-700 mb-1.5">{{ r.label }}</p>
<div class="space-y-1 text-[11px]">
<div class="flex justify-between gap-3">
<span class="text-slate-400 shrink-0">本机</span>
<span class="text-slate-700 text-right">{{ r.local }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-slate-400 shrink-0">服务器</span>
<span class="text-slate-700 text-right">{{ r.remote }}</span>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<button @click="resolveMerge('local')" :disabled="sync.busy"
class="w-full py-3 bg-indigo-600 text-white rounded-xl text-sm font-bold shadow-md active:scale-95 transition disabled:opacity-40">
冲突处保留本机(推荐)
</button>
<button @click="resolveMerge('remote')" :disabled="sync.busy"
class="w-full py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-bold active:scale-95 transition disabled:opacity-40">
冲突处改用服务器版本
</button>
<button @click="deferMerge" :disabled="sync.busy"
class="w-full py-2.5 text-[11px] font-medium text-slate-400 active:text-slate-600 transition disabled:opacity-40">
先不处理(保持登录,数据都不动)
</button>
</div>
<p class="text-center text-[10px] text-slate-400 mt-2.5 leading-relaxed">合并前会自动留一份本机快照。两边独有的数据一律双向保留。</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>
<!-- boot 自愈提示(方案 B):Vue 起不来时 v-cloak 白屏,中央只留一行小灰字,
不是卡片、无按钮;自愈成功或页面正常启动时整层移除。 -->
<div id="boot-heal" style="display:none;position:fixed;inset:0;z-index:9999;background:#f8fafc;align-items:center;justify-content:center;">
<p style="font-size:11px;color:#94a3b8;margin:0;">正在恢复连接…</p>
</div>
<!-- 薪酬核算纯模块:与 Node 测试共用同一份。
必须排在下面那块内联脚本之前,setup() 直接读 WHTPayroll。
它不依赖 Vue,也不依赖 DOM,加载顺序只要在内联脚本前面就行。
注意:这段注释里刻意不写出 script 开标签的字面量 ——
测试 harness 与 verify_payroll.js 都用正则抠内联脚本,
注释里出现那个字面量会被当成开标签,抠出来的就是注释残片(实测踩过)。 -->
<script src="shared/payroll.js"></script>
<!-- 离线优先的同步引擎:不依赖 Vue,独立于页面逻辑,方便以后套 APP 壳复用 -->
<script src="sync.js"></script>
<script>
// Vue 没加载成功时,v-cloak 会把整个 #app 遮成白屏。
// 白屏比闪一屏 {{ }} 强,但用户仍然不知道发生了什么,所以要给个明确提示。
//
// 这里只能确定一件事:Vue 现在不可用。
// 为什么不可用有多种可能——文件真的没部署、请求中途断开、被浏览器插件或中间设备
// 拦截、内容被截断。所以文案不替用户断定原因,真正的原因交给重试去实测并如实报出。
//
// 关键改动(2026-09-04):不再一上来就甩失败卡片。
// 实测服务端访问日志:手机端每次取 Vue 都要 400~500ms,而失败的那些请求日志里
// 压根没有记录 —— 即请求没到服务器,是链路丢包。这类失败绝大多数「重来一次就好」,
// 而原来的做法是立刻把卡片糊在用户脸上,让人手动点一下按钮去做机器该做的事。
// 那张卡除了逼用户点按钮,不提供任何它自己解决不了的信息。
// 现在改成:先静默自动重试,只有连续失败才显示卡片。
// 启动硬依赖清单。原来只盯 Vue,现在 shared/payroll.js 也是跑不起就白屏的一环,
// 而它同样是个要过网的文件——实测过这条链路会丢包,所以重试守卫必须一并盖住。
// probe 是“取回的文本里必然出现的串”,用来分辨“取到了但被截断”——不查字节数,
// 字节数会跟着版本变,忘了更新就是一个假阴性。
const BOOT_DEPS = [
{ name: 'Vue', src: 'vendor/vue.global.prod.js', probe: 'createApp', label: '渲染引擎' },
{ name: 'WHTPayroll', src: 'shared/payroll.js', probe: 'WHTPayroll', label: '薪酬核算模块' }
];
const bootMissing = BOOT_DEPS.filter((d) => typeof globalThis[d.name] === 'undefined');
if (bootMissing.length) {
const healLayer = document.getElementById('boot-heal');
// 技术细节全部走 console.warn,页面上永远只有一行「正在恢复连接…」。
const bootLog = (msg) => {
console.warn('[boot-guard] [' + new Date().toISOString() + '] ' + msg);
};
document.getElementById('app').remove();
healLayer.style.display = 'flex';
// reload 记账:取回成功后必须 reload 才能把 Vue 跑起来,不记次数会闪屏循环。
// 每会话最多 2 次 reload;额度用尽后不再 reload,转纯后台轮询(成功后下一次
// 自然刷新/重开页面即恢复),避免服务端真坏时无限闪屏。
const KEY = 'wht_boot_reload';
const MAX_RELOAD = 2;
const spent = Number(sessionStorage.getItem(KEY) || 0);
bootLog('启动依赖缺失: ' + bootMissing.map((d) => d.name + ' (' + d.src + ', probe=' + d.probe + ')').join(', '));
/** 把缺的依赖逐个取一遍并验完整性,成功返回总字节数。 */
async function refetch() {
let total = 0;
for (const d of bootMissing) {
// cache:'reload' 强制回源,绕过可能已经缓存下来的坏副本,
// 成功后好的副本会写回缓存,紧接着的 reload 才拿得到完整文件。
const r = await fetch(d.src, { cache: 'reload' });
if (!r.ok) throw new Error(d.src + ' 返回 HTTP ' + r.status);
const text = await r.text();
if (!text.includes(d.probe)) {
throw new Error(d.src + ' 内容不完整(' + text.length + ' 字节,缺 ' + d.probe + ')');
}
total += text.length;
}
return total;
}
// 静默自愈:退避轮询 2s→4s→8s→…封顶 30s;取到完整文件后:
// - reload 额度未用完 → 记账并 reload,用户只感觉「闪了一下」
// - 额度已用尽 → 不再 reload,继续留在自愈层;服务端修复后用户手动刷新即恢复
let delay = 2000;
async function healLoop() {
try {
const n = await refetch();
if (spent < MAX_RELOAD) {
sessionStorage.setItem(KEY, String(spent + 1));
bootLog('重取成功,共 ' + n + ' 字节,reload 恹复(第 ' + (spent + 1) + '/' + MAX_RELOAD + ' 次)');
location.reload();
} else {
bootLog('重取成功但 reload 额度已用尽(' + MAX_RELOAD + ' 次),等待用户手动刷新');
}
return;
} catch (e) {
bootLog('重取失败: ' + ((e && e.message) || String(e)) + ',' + delay + 'ms 后重试');
}
setTimeout(healLoop, delay);
delay = Math.min(delay * 2, 30000);
}
healLoop();
throw new Error('启动依赖缺失(' + bootMissing.map((d) => d.name).join(', ') + '),已切到静默自愈流程');
}
// 启动依赖缺失时的静默自愈层:Vue 正常加载后立即移除,避免残留遮罩。
// 正常路径(Vue 已就位)这行不执行;自愈路径会 throw,也到不了这里。
if (document.getElementById('boot-heal')) {
document.getElementById('boot-heal').remove();
}
const { createApp, ref, reactive, computed, watch, onMounted } = Vue;
// 薪酬口径全部住在 shared/payroll.js,这里只做“接线”:把纯函数包成 Vue 的 computed。
// 为什么不直接在模板里写 WHTPayroll.xxx:下面保留了一批同名薄包装(aggregateMonth / isCutEdge …),
// 模板与各调用处一行都不用改 —— 这次重构只换实现,不换接口,出问题好定位。
const P = WHTPayroll;
createApp({
setup() {
// ===== 默认规则与字段标签 =====
// 口径常量只存 shared/payroll.js 一份。之前两处各留一份,改时薪要改两个文件,
// 忘一个就是页面与测试对不上 —— 那种不一致最难查,因为两边各自都「自洽」。
const DEFAULTS = P.DEFAULTS;
// 设置字段的中文名:登录合并冲突对照时要给人看,不能直接抱 key
const SETTING_LABELS = P.SETTING_LABELS;
const settings = reactive({ ...DEFAULTS });
const records = ref({});
// 月度奖惩终稿:key = 'YYYY-MM'
// { status:'draft'|'final', finalAmount:Number|null, note:String, finalizedAt:ISOString }
// 次月 1–2 号汇总申报后,实际审核金额可能与日常累计不同(如累计 100、审核只认 80)。
// 定稿后所有发放口径都以终稿为准,日常每日记录通道保持不变、也不锁定。
const monthAdjust = ref({});
// ===== 工时口径:6–12h,0.5 步进 =====
// hInt/hFrac 专供日历格子拆开渲染:小数位占固定宽度槽位,8 与 8.5 的整数部才能左右对齐。
// clampHours 只归一「设置里的班次默认工时」,不改已保存的打卡记录
// (旧数据里 14h 这类越界值必须原样保留,用例已钉住)。
// sn 必须先判空:Number(null) 与 Number('') 都等于 0,少这一步「清空时薪输入框」会把时薪静默变成 0。
const { HOUR_MIN, HOUR_MAX, HOUR_STEP, hourOptions, hInt, hFrac, clampHours, sn } = P;
// 洗过的有效配置:所有算钱逻辑只认这个值,不直接读原始 settings。
// 包成 computed 是为了让 settings 每次改动都重洗一遍并触发下游重算。
// payDayA/payDayB 也必须一起洗 —— 它们现在参与日历发薪日判定,
// 拿到 undefined 时 payDayKind() 比较永不命中,角标与内描边会整个消失。
const cfg = computed(() => P.normalizeSettings(settings));
// ===== 页签 =====
const tab = ref('calendar');
watch(tab, (t) => { if (t === 'settings') loadMcpStatus(); });
// 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);
};
const newDraft = (shift) => P.newDraft(shift, cfg.value);
const draftRecord = ref(newDraft(cfg.value.defaultShift));
// ===== 日期辅助 =====
const { monthPrefix, keyOf, monthKeyOf, prevMonthOf, nextMonthOf } = P;
const daysInMonth = computed(() => P.daysInMonth(currentYear.value, currentMonth.value));
const firstDayOfWeek = computed(() => P.firstDayOfWeek(currentYear.value, currentMonth.value));
const getMonthAdjust = (y, m) => P.getMonthAdjust(monthAdjust.value, y, m);
const getRecord = (day) => records.value[keyOf(currentYear.value, currentMonth.value, day)];
// 这两个要读「今天」。模块里不允许自己取 now(否则测试基准每天都不一样),
// 所以由调用方决定:页面上就是省略实参,让模块回落到 new Date()。
const isToday = (day) => P.isSameDay(currentYear.value, currentMonth.value, day);
const isCurrentMonth = computed(() => P.isCurrentMonth(currentYear.value, currentMonth.value));
// moneyParts 把金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
// 它必须从 toFixed(1) 的结果上切,不能用 Math.floor 另算,
// 否则 99.96 会被 money() 进为 100.0、而拆分出 99.0,两处数字对不上。
const { fmt, money, signed, moneyParts } = P;
// ===== 核心聚合:本月 / 上月共用同一套口径 =====
// 口径本体在 shared/payroll.js。这里只把响应式数据打包成它要的 ctx,
// 保留同名薄包装是刻意的:模板与下面十几处调用一行都不用改。
//
// 口径要点(细节与用例见 payroll.js 顶部注释):
// 夜班跨天工时整段归开始打卡那天;满勤未达标 → 满勤奖按 0;
// 奖惩未经组长审核定稿 → 一律按 0(同一个道理)。
const payrollCtx = () => ({ records: records.value, monthAdjust: monthAdjust.value, cfg: cfg.value });
const aggregateMonth = (year, month) => P.aggregateMonth(year, month, payrollCtx());
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));
// 发放口径与两套记账口径一次算齐(细节见 payroll.js 的 flowsOf 注释):
// 当月 payDayA = 当月 1–cutoff 工时 × rate
// 当月 payDayB = 上月 cutoff+1–月底 工时 × rate + 上月奖惩净额
// 必须走同一个 flowsOf:结转与「次月实际到账」若各算一遍,迟早漂成两个数。
const flows = computed(() => P.flowsOf({ cur: monthStats.value, prev: prevStats.value, cfg: cfg.value }));
const payoutThisA = computed(() => flows.value.thisA);
const payoutThisB = computed(() => flows.value.thisB);
const payoutNextB = computed(() => flows.value.nextB);
// ===== 两套记账口径(这是页面之前最主要的歧义源) =====
// 归属制「挣得」:本月 1–月底干出来的钱,不管什么时候发。打一天卡当天就涨。
// 只含工时 + 已确认奖惩(满勤奖月底才确定,不混入挣得,单独展示)。
// 收付制「进账」:本月卡里实际进的钱,含上月结转部分。
// 两者永远不相等,必须各自具名、分区展示,不能都叫「本月」。
// 决策1:统一「X月挣得」= 本月劳动产生的全部价值(不管哪天发)。
// = 工时收入 + 满勤奖(未达 27 天为 0)+ 奖惩净额(未定稿取日常累计,已定稿取终稿)
// adjustNet 本身 = fullBonus + reviewAmount,正好是「满勤奖 + 奖惩净额」。
// 打卡页大字与汇总页「合计应得」共用这一个值,不再有两个不同的「挣得」。
const earnedTotal = computed(() => flows.value.earnedTotal);
const cashTotal = computed(() => flows.value.cashTotal);
// 「挣得」拆成两笔落地时间:上半月工时随本月 payDayA;下半月工时 + 奖惩结转次月 payDayB。
// 这一行是两套口径之间唯一的桥,解释了为何挣得 ≠ 进账。
// 注意:结转那笔只用「已确认的奖惩」,满勤奖月底才确定,不计入挣得(另作预估展示)。
// 决策2:结转 = 次月 payDayB 实际到账那一笔,必须含满勤奖(业务规则:满勤奖压到次月 10 号发)。
// 直接复用 payoutNextB,保证「结转」与「次月实际上账」永远同一个数,不再出现账目缺口。
const earnedCarry = computed(() => flows.value.carry);
const earnedSplit = computed(() => flows.value.split);
// 上半月那笔到没到账,只在看当前月份时才有「待发」说法;翻历史月份一律算已发。
const payDayAPassed = computed(() => P.payDayAPassed(currentYear.value, currentMonth.value, cfg.value));
// 处罚吃穿下半月工时时,结转那笔被 ¥0 兰住,于是 paid+carry > earnedTotal。页面需要明说。
const earnedFloored = computed(() => flows.value.floored);
// 综合时薪(含满勤与定稿奖惩后的实际每小时劳动收益)
const effectiveHourly = computed(() => {
const hrs = monthStats.value.totalHours;
return hrs > 0 ? (earnedTotal.value / hrs).toFixed(2) : '0.00';
});
const penaltyDetails = computed(() => P.penaltyDetails(currentYear.value, currentMonth.value, payrollCtx()));
const recordCount = computed(() => Object.keys(records.value).length);
// 看板主数字 = 统一口径的「X月挣得」,与汇总页「合计应得」严格同源。
// 明细行三项(工时收入 + 满勤奖 + 奖惩净额)相加必须严丝合缝等于这个大字。
const boardMain = computed(() => moneyParts(earnedTotal.value));
// 距满勤还差几天(达标后为 0),供顶部进度胶囊使用
const fullDaysLeft = computed(() => P.fullDaysLeft(monthStats.value, cfg.value));
const hasPenalty = (day) => {
const r = getRecord(day);
return !!r && r.status === 'work' && (Number(r.contraband_missed || 0) > 0 || Number(r.other_penalty || 0) > 0);
};
// 发薪日判定:'a' = 当月 payDayA(上半月工时,线上);'b' = 当月 payDayB(上月下半月工时 + 上月奖惩,线下)
// 只做统计标注,不处理"遇周末顺延",实际到账日以单位为准。
const payDayKind = (day) => P.payDayKind(day, cfg.value);
// 选中日如果是发薪日,打卡抽屉顶部展示该笔到账(不打断打卡主流程)
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) => P.isCutEdge(day, cfg.value);
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; loadMcpStatus(); };
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 = () => {
// 原地归一:非正扣款回落 null;非「上班」日清空全部奖惩字段
P.normalizeDraft(draftRecord.value);
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, allowRegister: null, apiBase: '', busy: false, hold: null
});
const syncPanelOpen = ref(false);
const authMode = ref('login'); // 'login' | 'register'
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(() => {
if (sync.hold) return '有冲突·已暂停同步';
return ({
idle: '未登录·仅存本机',
ready: sync.pending > 0 ? `待上传 ${sync.pending} 项` : '已同步',
syncing: '同步中…',
offline: '连不上服务器·本地照常用',
error: '同步出错'
})[sync.status] || sync.status;
});
const syncStatusClass = computed(() => {
if (sync.hold) return 'text-amber-700 bg-amber-100';
return ({
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);
window.WHTSync.releaseSync(); // 用户已拍板,解除同步冻结
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;
});
// 先不合并:保持登录但冻结自动同步。
// 必须冻结 —— 否则下一次改动触发的 syncNow 会把远端值静默盖到本机冲突日上,
// 用户看到的就是「我选了不处理,数据却自己变了」。
const deferMerge = () => {
mergeOpen.value = false;
window.WHTSync.holdSync('login-conflict');
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(() => {});
};
// ===== AI 接入 (MCP):状态/开关/令牌管理(走 /api/mcp/*,登录态鉴权)=====
// 开关与令牌存服务器 DB,改动即时生效;令牌明文只在生成/复制响应里出现,页面不留存。
const mcp = reactive({ configured: false, enabled: false, masked: '', busy: false, loaded: false, authNeeded: false });
const mcpCopied = ref(false);
const mcpBadge = computed(() => {
if (!mcp.loaded) return { text: '…', cls: 'bg-slate-100 text-slate-400' };
if (mcp.authNeeded) return { text: '本设备请先登录', cls: 'bg-amber-50 text-amber-600' };
if (!mcp.configured) return { text: '未配置', cls: 'bg-slate-100 text-slate-400' };
return mcp.enabled
? { text: '已开启', cls: 'bg-emerald-50 text-emerald-600' }
: { text: '已关闭', cls: 'bg-amber-50 text-amber-600' };
});
// 复用同步引擎的 apiBase 与登录 token:页面能同步就能管 MCP,不另做一套鉴权
const mcpHeaders = () => {
const h = { 'content-type': 'application/json' };
const token = window.WHTSync && window.WHTSync.state.token;
if (token) h.authorization = 'Bearer ' + token;
return h;
};
const loadMcpStatus = async () => {
try {
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/status', { headers: mcpHeaders() });
const d = await res.json();
mcp.authNeeded = res.status === 401 || res.status === 403;
if (d && d.ok) {
mcp.configured = !!d.configured;
mcp.enabled = !!d.enabled;
mcp.masked = d.masked || '';
if (d.configured) mcp.authNeeded = false;
}
} catch (e) { /* 静态模式/离线:保持未知态,不干扰记账主功能 */ }
mcp.loaded = true;
};
const toggleMcp = async () => {
if (mcp.busy) return;
mcp.busy = true;
try {
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/enabled', {
method: 'POST', headers: mcpHeaders(), body: JSON.stringify({ enabled: !mcp.enabled })
});
const d = await res.json();
if (!res.ok || !d.ok) throw new Error(d.error || 'HTTP ' + res.status);
mcp.enabled = d.enabled;
showToast(d.enabled ? 'MCP 已开启,客户端即可接入' : 'MCP 已关闭,客户端接入立即失效');
} catch (e) {
showToast('操作失败:' + (e.message || '未知错误'));
} finally { mcp.busy = false; }
};
// 生成/重置令牌:生成后自动复制明文(仅此一次,服务端不留明文出口以外的副本)
const newMcpToken = async () => {
if (mcp.busy) return;
if (mcp.configured && !confirm('重置令牌?正在使用的旧令牌会立即失效。')) return;
mcp.busy = true;
try {
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/token', { method: 'POST', headers: mcpHeaders() });
const d = await res.json();
if (!res.ok || !d.ok) throw new Error(res.status === 401 ? '本设备请先登录' : (d.error || 'HTTP ' + res.status));
mcp.configured = true;
mcp.enabled = true;
mcp.authNeeded = false;
writeClipboard(d.token, () => {
mcpCopied.value = true;
setTimeout(() => { mcpCopied.value = false; }, 2500);
});
showToast('令牌已生成并复制(明文仅此一次)');
await loadMcpStatus();
} catch (e) {
showToast('生成失败:' + (e.message || '未知错误'));
} finally { mcp.busy = false; }
};
const copyMcpToken = async () => {
if (mcp.busy || !mcp.configured) return;
mcp.busy = true;
try {
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/token', { headers: mcpHeaders() });
const d = await res.json();
if (!res.ok || !d.ok) throw new Error(d.error || 'HTTP ' + res.status);
writeClipboard(d.token, () => {
mcpCopied.value = true;
setTimeout(() => { mcpCopied.value = false; }, 2000);
});
} catch (e) {
showToast('取回失败:' + (e.message || '未知错误'));
} finally { mcp.busy = false; }
};
// ===== 载入与持久化 =====
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.allowRegister = s.allowRegister;
sync.hold = s.hold;
});
sync.apiBase = window.WHTSync.getApiBase() === location.origin ? '' : window.WHTSync.getApiBase();
// 探一下服务端在不在、有没有账号,决定设置页该显示「创建账号」还是「登录」
window.WHTSync.health().catch(() => {});
}
});
// PWA:注册 Service Worker。放在 onMounted 里跑:
// 1) 不阻塞首次启动;2) 与启动守卫天然隔离 —— SW 注册失败绝不影响页面本身;
// 3) verify_payroll.js / app-harness 的 document 桩件没有 serviceWorker 字段,onMounted 里可安全跳过。
// HTTPS 才有 navigator.serviceWorker,HTTP 裸访问自动跳过,页面退化为普通网页。
onMounted(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch((e) => {
console.warn('[pwa] SW 注册失败(不影响使用):', e && e.message);
});
}
});
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, effectiveHourly,
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,
authMode,
sync, syncPanelOpen, loginForm, syncStatusText, syncStatusClass, syncTimeText,
mergeOpen, mergeInfo, mergeRows, resolveMerge, deferMerge, reopenMerge,
doLogin, doRegister, doLogout, doSyncNow, doPullAll, doPushAll, saveApiBase,
mcp, mcpCopied, mcpBadge, toggleMcp, copyMcpToken, newMcpToken,
saveRecord, deleteRecord, copyReport,
exportData, copyData, importData, resetSettings, clearMonth, clearAll
};
}
}).mount('#app');
</script>
</body>
</html>