这是第一个包含完整工作区的提交,也是后续「拆 SFC / 上 Vite」的回滚基线。 此前的提交均为 index.html 单文件历史快照,由 .bak 文件重建。 当前状态(提交前已实测): - index.html 内联薪酬算法已删除,改调 WHTPayroll - shared/payroll.js 为唯一薪酬实现,浏览器与 Node 共用 - 对外接口未变:aggregateMonth / isCutEdge / payDayKind 等保留同名薄包装 未入库内容见 .gitignore:依赖、Chrome 验证 profile、验证截图、 运行时 sqlite 数据与日志、时间戳备份、.trash。
2034 lines
130 KiB
HTML
2034 lines
130 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||
<title>工时薪酬助手</title>
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234F46E5'/%3E%3Cpath d='M16 8v8l5 3' stroke='white' stroke-width='2.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E" />
|
||
<!-- 本地依赖,不走 CDN:
|
||
原来用 cdn.tailwindcss.com 是运行时 JIT 编译器,它必须先把页面扫一遍再注入样式,
|
||
中间必然有一段无样式窗口;且两个 CDN 在国内不稳,挂了页面就只剩裸模板。
|
||
现改为预生成的静态 CSS + 本地 Vue,离线可用、无网络抗手。 -->
|
||
<link rel="stylesheet" href="vendor/tailwind.css" />
|
||
<script src="vendor/vue.global.prod.js"></script>
|
||
<style>
|
||
/* Vue 接管前先藏住模板,避开刷新瞬间闪出 {{ }} 原文。
|
||
mount() 一执行 v-cloak 属性就被移除,这条规则自动失效。 */
|
||
[v-cloak] { visibility: hidden; }
|
||
</style>
|
||
<style>
|
||
/* ===== 设计令牌(与 Tailwind 默认色板一一对应,便于两种写法混用) ===== */
|
||
:root {
|
||
--ink: #1E293B; /* 主色·深黛蓝 = slate-800 */
|
||
--accent: #4F46E5; /* 强调·靛蓝 = indigo-600 */
|
||
--pos-bg: #ECFDF5; /* 正向·薄荷底 = emerald-50 */
|
||
--pos-fg: #059669; /* 正向·翡翠字 = emerald-600 */
|
||
--neg-bg: #FFF1F2; /* 负向·玫瑰底 = rose-50 */
|
||
--neg-fg: #E11D48; /* 负向·玫瑰字 = rose-600 */
|
||
--page: #F8FAFC; /* 页面底色 = slate-50 */
|
||
--line: #F1F5F9; /* 卡片描边 = slate-100 */
|
||
|
||
/* 圆角三级阶梯:外层容器 / 内嵌块 / 胶囊标签 */
|
||
--r-out: 16px;
|
||
--r-in: 10px;
|
||
--r-pill: 999px;
|
||
/* 统一描边与落影 */
|
||
--hair: 1px solid rgba(0, 0, 0, .06);
|
||
--lift: 0 4px 20px -2px rgba(0, 0, 0, .05);
|
||
}
|
||
|
||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||
|
||
body {
|
||
-webkit-tap-highlight-color: transparent;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||
"Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
}
|
||
|
||
/* 等宽数字:账单上下对齐,数值刷新时不左右跳动 */
|
||
.font-mono-num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; }
|
||
|
||
/* 统一卡片:外层 16px 圆角 + 一根发丝描边 + 大范围弥散落影。
|
||
落影用大 blur、小 alpha,读起来是「浮起来」而不是「加了一道灰边」。 */
|
||
.card {
|
||
border-radius: var(--r-out);
|
||
background: #ffffff;
|
||
border: var(--hair);
|
||
box-shadow: var(--lift);
|
||
}
|
||
|
||
/* 内嵌块(卡片里的小格子):圆角收一档到 10px,形成外大内小的嵌套层级 */
|
||
.inset { border-radius: var(--r-in); }
|
||
|
||
/* 金额柔色徽章:正向薄荷、负向玫瑰、零值中性。
|
||
把符号和数字一起裹进底色里,靠色相表意,不再靠一个生硬的加减号。 */
|
||
.amt {
|
||
display: inline-flex; align-items: center;
|
||
padding: 2px 8px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 11px; line-height: 1.45; font-weight: 700;
|
||
font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1;
|
||
white-space: nowrap;
|
||
}
|
||
.amt-pos { background: var(--pos-bg); color: var(--pos-fg); }
|
||
.amt-neg { background: var(--neg-bg); color: var(--neg-fg); }
|
||
.amt-zero { background: #F1F5F9; color: #94A3B8; }
|
||
|
||
/* Lucide 线性图标:尺寸由自身 font-size 决定(用 Tailwind text-· 类控制),
|
||
避免和 Tailwind 的宽高工具类抢层叠顺序。
|
||
注意:注释里不能出现星号加斜杠,否则会提前闭合注释、把下面整条规则吃掉。 */
|
||
.ico {
|
||
width: 1em; height: 1em; flex: none;
|
||
display: inline-block; vertical-align: -.125em;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 1.5;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
/* 上下半月分界:沿网格底缘描出柔和虚线。
|
||
不单独插一行分割元素,那会把下半月推到新行开头、搞乱星期对齐。
|
||
Tailwind CDN 运行时注入的样式可能排在本文件之后,所以用 !important 压住单边颜色。 */
|
||
.cut-edge { border-bottom: 2px dashed rgba(148, 163, 184, .55) !important; }
|
||
|
||
/* ===== 发薪日标记 =====
|
||
只做"标注"不做"改色":格子底色仍归班次所有(白班橙/夜班靛蓝),
|
||
发薪日用内描边 + 右上角 ¥ 字符表达,避免两套语义抢同一块背景。
|
||
今天的 ring 优先级更高,遇到重合时只留 ¥ 角标。 */
|
||
.pay-ring-a { box-shadow: inset 0 0 0 1.5px rgba(79, 70, 229, .38); }
|
||
.pay-ring-b { box-shadow: inset 0 0 0 1.5px rgba(217, 119, 6, .40); }
|
||
.pay-mark {
|
||
font-size: 9px; font-weight: 800; line-height: 1;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.pay-mark.pay-a { color: #4f46e5; }
|
||
.pay-mark.pay-b { color: #d97706; }
|
||
|
||
/* 定稿状态标签 */
|
||
.stat-tag {
|
||
display: inline-flex; align-items: center; gap: 3px;
|
||
font-size: 10px; font-weight: 700; line-height: 1;
|
||
padding: 3px 7px; border-radius: 999px; white-space: nowrap;
|
||
}
|
||
.stat-draft { background: #FEF3C7; color: #B45309; }
|
||
.stat-final { background: #D1FAE5; color: #047857; }
|
||
|
||
/* 归属期徽章:标清一笔钱是哪个月挣的。进账列表里上月/本月混排,
|
||
原本只写在小灰字副标题里、层级太弱,改用底色色块区分。 */
|
||
.own-badge {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
font-size: 9px; font-weight: 700; line-height: 1;
|
||
padding: 3px 5px; border-radius: 6px; white-space: nowrap; min-width: 32px;
|
||
}
|
||
.own-prev { background: #E2E8F0; color: #475569; }
|
||
.own-cur { background: #E0E7FF; color: #4338CA; }
|
||
|
||
/* ===== 日历格胶囊结构(第三轮) =====
|
||
班次与工时整合为紧凑胶囊,避免 hInt/hFrac 拆分小数导致的折行;
|
||
左下实心属性点移除,仅保留右上状态点。 */
|
||
.calendar-cell {
|
||
display: flex; flex-direction: column;
|
||
justify-content: space-between; align-items: center;
|
||
height: 56px; padding: 4px 2px; box-sizing: border-box;
|
||
}
|
||
.cell-top {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
width: 100%; padding: 0 4px;
|
||
}
|
||
.day-num {
|
||
font-size: 13px; font-weight: 500; color: #1f2937;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.event-dot {
|
||
width: 5px; height: 5px; border-radius: 50%; background-color: #10b981;
|
||
}
|
||
.event-dot.rose { background-color: #f43f5e; }
|
||
.cell-bottom {
|
||
display: flex; justify-content: center; align-items: center; width: 100%;
|
||
}
|
||
.shift-pill {
|
||
display: inline-flex; align-items: baseline; justify-content: center;
|
||
gap: 1.5px; padding: 1px 4px; border-radius: 999px;
|
||
max-width: 96%; white-space: nowrap; /* 严禁折行:整数/小数都留在同一行 */
|
||
}
|
||
.shift-pill .shift-type { font-size: 10px; font-weight: 600; line-height: 1; }
|
||
.shift-pill .shift-time {
|
||
font-size: 10px; font-weight: 600; line-height: 1;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.shift-night { background-color: #eef2ff; color: #4f46e5; }
|
||
.shift-day { background-color: #fff7ed; color: #ea580c; }
|
||
.status-text { font-size: 11px; color: #9ca3af; }
|
||
.status-text.sky { color: #0ea5e9; }
|
||
.status-text.empty { color: #d1d5db; }
|
||
.status-text.rest { color: #9ca3af; }
|
||
|
||
/* 深色看板落影:带品牌色调(靛蓝),比中性黑更贴合渐变底,
|
||
让下方浅色卡片读起来像"从看板底下滑出"而不是被硬线切开。 */
|
||
.board-shadow {
|
||
box-shadow: 0 6px 16px -6px rgba(49, 46, 129, .38),
|
||
0 2px 6px -2px rgba(30, 41, 59, .16);
|
||
}
|
||
|
||
/* 空状态容器:极浅底 + 虚线描边,暗示"这里等着被填上",
|
||
比一行灰字更像一个可以点进去的坑位。 */
|
||
.empty-state {
|
||
border: 1.5px dashed #E2E8F0; /* slate-200 */
|
||
border-radius: 14px;
|
||
background: #FBFCFE;
|
||
}
|
||
|
||
/* 半透明胶囊标签(挂在深色看板上)。
|
||
flex:none + nowrap 一起用:胶囊既不被压扁也不折行,超宽时由父容器横向滚动兜底。 */
|
||
.pill {
|
||
display: inline-flex; align-items: center; gap: 3.5px;
|
||
flex: none;
|
||
background: rgba(255, 255, 255, .14);
|
||
border: 1px solid rgba(255, 255, 255, .16);
|
||
border-radius: 999px;
|
||
padding: 4px 9px;
|
||
font-size: 11px; line-height: 1.25; font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 字重阶梯:标题 fw-7 / 数值 fw-6 / 辅助 fw-4,拉大对比 */
|
||
.fw-7 { font-weight: 700; }
|
||
.fw-6 { font-weight: 600; }
|
||
.fw-4 { font-weight: 400; }
|
||
|
||
.drawer-enter-active, .drawer-leave-active { transition: transform .25s cubic-bezier(.16,1,.3,1), opacity .25s ease; }
|
||
.drawer-enter-from, .drawer-leave-to { transform: translateY(100%); opacity: 0; }
|
||
.fade-enter-active, .fade-leave-active { transition: opacity .15s ease; }
|
||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||
.safe-bottom { padding-bottom: env(safe-area-inset-bottom); }
|
||
</style>
|
||
</head>
|
||
<body class="bg-slate-200">
|
||
|
||
<!-- ===== Lucide 图标 sprite(内联,无外部依赖;描边由 .ico 统一给 1.5) ===== -->
|
||
<svg id="sprite" aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
|
||
<symbol id="i-award" viewBox="0 0 24 24"><path d="m15.477 12.89 1.515 8.526a.5.5 0 0 1-.81.47l-3.58-2.687a1 1 0 0 0-1.197 0l-3.586 2.686a.5.5 0 0 1-.81-.469l1.514-8.526" /><circle cx="12" cy="8" r="6" /></symbol>
|
||
<symbol id="i-briefcase" viewBox="0 0 24 24"><path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" /><rect width="20" height="14" x="2" y="6" rx="2" /></symbol>
|
||
<symbol id="i-calendar-clock" viewBox="0 0 24 24"><path d="M16 14v2.2l1.6 1" /><path d="M16 2v4" /><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5" /><path d="M3 10h5" /><path d="M8 2v4" /><circle cx="16" cy="16" r="6" /></symbol>
|
||
<symbol id="i-calendar-days" viewBox="0 0 24 24"><path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /><path d="M8 14h.01" /><path d="M12 14h.01" /><path d="M16 14h.01" /><path d="M8 18h.01" /><path d="M12 18h.01" /><path d="M16 18h.01" /></symbol>
|
||
<symbol id="i-chart-column" viewBox="0 0 24 24"><path d="M3 3v16a2 2 0 0 0 2 2h16" /><path d="M18 17V9" /><path d="M13 17V5" /><path d="M8 17v-3" /></symbol>
|
||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></symbol>
|
||
<symbol id="i-chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6" /></symbol>
|
||
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
||
<symbol id="i-clock" viewBox="0 0 24 24"><path d="M12 6v6l4 2" /><circle cx="12" cy="12" r="10" /></symbol>
|
||
<symbol id="i-coffee" viewBox="0 0 24 24"><path d="M10 2v2" /><path d="M14 2v2" /><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1" /><path d="M6 2v2" /></symbol>
|
||
<symbol id="i-coins" viewBox="0 0 24 24"><circle cx="8" cy="8" r="6" /><path d="M18.09 10.37A6 6 0 1 1 10.34 18" /><path d="M7 6h1v4" /><path d="m16.71 13.88.7.71-2.82 2.82" /></symbol>
|
||
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol>
|
||
<symbol id="i-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5V19A9 3 0 0 0 21 19V5" /><path d="M3 12A9 3 0 0 0 21 12" /></symbol>
|
||
<symbol id="i-cloud" viewBox="0 0 24 24"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
|
||
<symbol id="i-refresh-cw" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M8 16H3v5" /></symbol>
|
||
<symbol id="i-log-out" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /></symbol>
|
||
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m7 10 5 5 5-5" /></symbol>
|
||
<symbol id="i-hourglass" viewBox="0 0 24 24"><path d="M5 22h14" /><path d="M5 2h14" /><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22" /><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2" /></symbol>
|
||
<symbol id="i-lightbulb" viewBox="0 0 24 24"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" /><path d="M9 18h6" /><path d="M10 22h4" /></symbol>
|
||
<symbol id="i-minus" viewBox="0 0 24 24"><path d="M5 12h14" /></symbol>
|
||
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol>
|
||
<symbol id="i-notebook-pen" viewBox="0 0 24 24"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></symbol>
|
||
<symbol id="i-party-popper" viewBox="0 0 24 24"><path d="M5.8 11.3 2 22l10.7-3.79" /><path d="M4 3h.01" /><path d="M22 8h.01" /><path d="M15 2h.01" /><path d="M22 20h.01" /><path d="m22 2-2.24.75a2.9 2.9 0 0 0-1.96 3.12c.1.86-.57 1.63-1.45 1.63h-.38c-.86 0-1.6.6-1.76 1.44L14 10" /><path d="m22 13-.82-.33c-.86-.34-1.82.2-1.98 1.11c-.11.7-.72 1.22-1.43 1.22H17" /><path d="m11 2 .33.82c.34.86-.2 1.82-1.11 1.98C9.52 4.9 9 5.52 9 6.23V7" /><path d="M11 13c1.93 1.93 2.83 4.17 2 5-.83.83-3.07-.07-5-2-1.93-1.93-2.83-4.17-2-5 .83-.83 3.07.07 5 2Z" /></symbol>
|
||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14" /><path d="M12 5v14" /></symbol>
|
||
<symbol id="i-rotate-ccw" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /></symbol>
|
||
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" /></symbol>
|
||
<symbol id="i-shield-alert" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="M12 8v4" /><path d="M12 16h.01" /></symbol>
|
||
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" /></symbol>
|
||
<symbol id="i-trash-2" viewBox="0 0 24 24"><path d="M10 11v6" /><path d="M14 11v6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||
<symbol id="i-triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /><path d="M12 9v4" /><path d="M12 17h.01" /></symbol>
|
||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 3v12" /><path d="m17 8-5-5-5 5" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||
<symbol id="i-wallet" viewBox="0 0 24 24"><path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1" /><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4" /></symbol>
|
||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></symbol>
|
||
</svg>
|
||
|
||
<div id="app" v-cloak class="max-w-md mx-auto min-h-screen bg-slate-50 flex flex-col shadow-xl relative">
|
||
|
||
<!-- ===== 顶部资产看板 ===== -->
|
||
<header class="text-white px-5 pt-5 pb-5 rounded-b-3xl board-shadow shrink-0 relative overflow-hidden"
|
||
style="background-image: radial-gradient(125% 125% at 85% -10%, #6366F1 0%, rgba(99,102,241,0) 58%), linear-gradient(135deg, #1E293B 0%, #312E81 56%, #4F46E5 100%);">
|
||
<!-- 月份切换(日历/汇总) -->
|
||
<div class="flex justify-between items-center">
|
||
<div class="flex items-center space-x-1">
|
||
<button @click="changeMonth(-1)" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center"><svg class="ico text-base"><use href="#i-chevron-left" /></svg></button>
|
||
<span class="text-base font-bold tracking-wide font-mono-num">{{ currentYear }}年{{ currentMonth + 1 }}月</span>
|
||
<button @click="changeMonth(1)" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center"><svg class="ico text-base"><use href="#i-chevron-right" /></svg></button>
|
||
</div>
|
||
<div class="flex items-center space-x-1">
|
||
<button v-if="!isCurrentMonth" @click="goToday" class="pill active:scale-95 transition">回今天</button>
|
||
<button @click="openSettings" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center" title="设置"><svg class="ico text-base"><use href="#i-settings" /></svg></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主数字:本月「挣得」(仅日历页)。¥ 与小数位缩小,整数部放大,形成阶梯感。
|
||
注意:这里故意用归属制(本月干出来的)而不是现金流。日历页的核心动作是打卡,
|
||
用归属制打一天卡数字当场就涨;用现金流则毫无反馈(影响的是下一个发薪日)。 -->
|
||
<div v-if="tab === 'calendar'" class="mt-4">
|
||
<!-- 第一层:本月总劳动应得(归属制唯一口径,与汇总页「合计应得」同源) -->
|
||
<p class="text-[11px] text-indigo-100/70 font-medium flex items-center gap-1.5">
|
||
<svg class="ico text-[13px]"><use href="#i-coins" /></svg>{{ currentMonth + 1 }}月总劳动应得
|
||
</p>
|
||
<!-- 大字旁只放「目标达成状态」,不再放重复金额(金额已在下方平铺算式里) -->
|
||
<p class="mt-1 flex items-end flex-wrap gap-y-1 font-mono-num leading-none">
|
||
<span class="text-base font-bold text-indigo-100/80 mb-[3px] mr-0.5">¥</span>
|
||
<span class="text-[32px] font-bold tracking-tight">{{ boardMain.int }}</span>
|
||
<span class="text-base font-bold text-indigo-100/80 mb-[3px]">{{ boardMain.frac }}</span>
|
||
<!-- 达标:金色徽章;未达标:进度激励胶囊。两者都不重复金额。 -->
|
||
<span v-if="monthStats.workDays >= settings.fullDays"
|
||
class="ml-2 mb-[3px] pill" style="background:rgba(251,191,36,.2); color:#fcd34d; border:1px solid rgba(251,191,36,.4);">
|
||
<svg class="ico text-[12px]"><use href="#i-award" /></svg>
|
||
<b class="font-bold">已达满勤</b><span class="text-[9px] font-mono-num">¥{{ settings.fullBonusPerHour }}/h</span>
|
||
</span>
|
||
<span v-else-if="monthStats.workDays > 0" class="ml-2 mb-[3px] pill">
|
||
<svg class="ico text-[12px] text-indigo-200"><use href="#i-hourglass" /></svg>
|
||
出勤 <b class="font-mono-num font-bold">{{ monthStats.workDays }}/{{ settings.fullDays }}</b>
|
||
<span class="text-indigo-100/60">· 差 {{ fullDaysLeft }} 天满勤</span>
|
||
</span>
|
||
</p>
|
||
|
||
<!-- 第二层:平铺算式。三项相加严丝合缝等于上方大字,可一眼心算核对。
|
||
奖惩与满勤同一口径:未定稿(次月初组长审核前)一律按 ¥0 计,不进大字。 -->
|
||
<div class="mt-3 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[11px] font-mono-num text-indigo-100/85">
|
||
<span class="whitespace-nowrap">工时 <b class="font-bold text-amber-200">{{ money(monthStats.basePay) }}</b><span class="text-indigo-100/50">({{ fmt(monthStats.totalHours) }}h)</span></span>
|
||
<span class="text-indigo-100/45">+</span>
|
||
<span class="whitespace-nowrap">满勤 <b class="font-bold text-amber-200">{{ money(monthStats.fullBonus) }}</b></span>
|
||
<span class="text-indigo-100/45">+</span>
|
||
<span class="whitespace-nowrap">奖惩 <b class="font-bold" :class="monthStats.reviewNet > 0 ? 'text-emerald-300' : (monthStats.reviewNet < 0 ? 'text-rose-300' : 'text-indigo-100/70')">{{ signed(monthStats.reviewNet) }}</b>
|
||
<span class="ml-1 text-[9px] font-sans" :class="monthStats.finalized ? 'text-emerald-300' : 'text-indigo-100/55'">{{ monthStats.finalized ? '已定稿' : '未定稿' }}</span>
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 第三层:现金流降级为弱化说明条。与上方「应得」量级不同,必须分区,不再挤在一起。 -->
|
||
<div class="mt-3 rounded-xl px-3 py-2.5 space-y-1" style="background:rgba(255,255,255,.09); border:1px solid rgba(255,255,255,.13);">
|
||
<p class="text-[10px] font-bold text-indigo-100/70 flex items-center gap-1">
|
||
<svg class="ico text-[12px]"><use href="#i-wallet" /></svg>这笔钱什么时候到手
|
||
</p>
|
||
<div class="flex items-center justify-between gap-2 text-[11px]">
|
||
<span class="text-indigo-100/70">{{ currentMonth + 1 }}/{{ settings.payDayA }} 上半月纯工时<span class="text-indigo-100/45">({{ payDayAPassed ? '已发' : '待发' }})</span></span>
|
||
<b class="font-mono-num font-bold text-indigo-50 shrink-0">{{ money(earnedSplit.paid) }}</b>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-2 text-[11px]">
|
||
<span class="text-indigo-100/70">{{ nextLabel.month + 1 }}/{{ settings.payDayB }} 下半月+奖惩+满勤<span class="text-indigo-100/45">(结转)</span></span>
|
||
<b class="font-mono-num font-bold text-indigo-50 shrink-0">{{ money(earnedSplit.carry) }}</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ===== 页面容器 ===== -->
|
||
<main class="flex-1 pb-24">
|
||
|
||
<!-- ========== ① 日历打卡 ========== -->
|
||
<section v-show="tab === 'calendar'" class="px-4 pt-5 pb-4">
|
||
<div class="grid grid-cols-7 text-center text-[11px] font-semibold text-slate-400 mb-1.5">
|
||
<span class="text-rose-400">日</span><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span class="text-indigo-400">六</span>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-7 gap-1.5">
|
||
<div v-for="b in firstDayOfWeek" :key="'b'+b" class="h-[64px]"></div>
|
||
|
||
<div v-for="day in daysInMonth" :key="day" @click="openDayDrawer(day)"
|
||
class="h-[64px] rounded-xl p-0.5 relative active:scale-95 transition cursor-pointer overflow-hidden"
|
||
:class="cellClass(day)">
|
||
<div class="calendar-cell">
|
||
<!-- 顶部:日期 + 右上状态点(查获绿 / 处罚玫红) -->
|
||
<div class="cell-top">
|
||
<span v-if="isToday(day)" class="w-[17px] h-[17px] rounded-full bg-indigo-600 text-white text-[10px] font-bold flex items-center justify-center font-mono-num shrink-0">{{ day }}</span>
|
||
<span v-else class="day-num">{{ day }}</span>
|
||
<span class="flex items-center space-x-0.5 pt-0.5">
|
||
<span v-if="payDayKind(day)" class="pay-mark" :class="payDayKind(day) === 'a' ? 'pay-a' : 'pay-b'">¥</span>
|
||
<span v-if="getRecord(day)?.status === 'work' && getRecord(day)?.contraband_found > 0" class="event-dot"></span>
|
||
<span v-if="hasPenalty(day)" class="event-dot rose"></span>
|
||
</span>
|
||
</div>
|
||
<!-- 底部:班次+工时胶囊(白=橙/夜=靛蓝),休/假/空为纯文字 -->
|
||
<div class="cell-bottom">
|
||
<span v-if="getRecord(day)?.status === 'work'"
|
||
class="shift-pill"
|
||
:class="getRecord(day).shift_type === 'day' ? 'shift-day' : 'shift-night'">
|
||
<span class="shift-type">{{ getRecord(day).shift_type === 'day' ? '白' : '夜' }}</span>
|
||
<span class="shift-time">{{ fmt(getRecord(day).hours) }}h</span>
|
||
</span>
|
||
<span v-else-if="getRecord(day)?.status === 'rest'" class="status-text rest">休</span>
|
||
<span v-else-if="getRecord(day)?.status === 'leave'" class="status-text sky">假</span>
|
||
<span v-else class="status-text empty">—</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 虚线含义说明:可展开图例(默认收叠,点击展开) -->
|
||
<button @click="legendOpen = !legendOpen" class="mt-3.5 mx-auto flex items-center justify-center gap-1 text-[10px] text-slate-400 active:scale-95 transition select-none">
|
||
<svg class="ico text-[13px]" :class="legendOpen ? 'rotate-180' : ''" style="transition:transform .2s ease"><use href="#i-chevron-down" /></svg>
|
||
<span>图例</span>
|
||
</button>
|
||
<div v-show="legendOpen" class="mt-1.5 space-y-1">
|
||
<div class="flex items-center justify-center gap-2 text-[10px] text-slate-400">
|
||
<span class="inline-flex items-center gap-1.5">
|
||
<span class="w-4 border-t-2 border-dashed border-slate-300 inline-block"></span>
|
||
<span>线上 → 当月<b class="font-mono-num font-semibold text-slate-500">{{ settings.payDayA }}</b>号发</span>
|
||
</span>
|
||
<span class="text-slate-200">·</span>
|
||
<span>线下 → 次月<b class="font-mono-num font-semibold text-slate-500">{{ settings.payDayB }}</b>号发</span>
|
||
</div>
|
||
<p class="text-center text-[10px] text-slate-300">奖励与处罚统一在次月{{ settings.payDayB }}号结算</p>
|
||
<div class="flex items-center justify-center gap-2.5 text-[10px] text-slate-400 pt-0.5">
|
||
<span class="inline-flex items-center gap-1"><b class="pay-mark pay-a">¥</b>当月{{ settings.payDayA }}号发薪</span>
|
||
<span class="text-slate-200">·</span>
|
||
<span class="inline-flex items-center gap-1"><b class="pay-mark pay-b">¥</b>当月{{ settings.payDayB }}号发薪</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ========== ② 汇总 ========== -->
|
||
<section v-show="tab === 'summary'" class="px-4 pt-5 pb-4 space-y-3.5">
|
||
|
||
<!-- 【归属制】X月挣得:本月干出来的钱,不管什么时候发。
|
||
与下面的「进账」卡严格分区,两者不得再共用「本月到账」这种模糊叫法。 -->
|
||
<div class="card overflow-hidden">
|
||
<div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between">
|
||
<span class="text-[13px] font-bold text-slate-800 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-coins" /></svg>{{ currentMonth + 1 }}月总劳动应得</span>
|
||
<span class="text-[10px] font-normal text-slate-400">{{ currentMonth + 1 }}/1–{{ currentMonth + 1 }}/{{ daysInMonth }}</span>
|
||
</div>
|
||
<!-- 三项构成与打卡页平铺算式严格一一对应:工时 + 满勤 + 奖惩 = 合计应得 -->
|
||
<div class="px-5 py-3.5 space-y-1.5 text-xs">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-slate-500">工时收入<span class="text-slate-400 font-mono-num">({{ fmt(monthStats.totalHours) }}h)</span></span>
|
||
<span class="flex items-center gap-1.5">
|
||
<span class="stat-tag stat-final">确定</span>
|
||
<span class="font-bold text-slate-800 font-mono-num">{{ money(monthStats.basePay) }}</span>
|
||
</span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-slate-500">满勤奖<span class="text-slate-400">{{ monthStats.workDays >= settings.fullDays ? '(已达' + settings.fullDays + '天)' : '(未达' + settings.fullDays + '天,不计)' }}</span></span>
|
||
<span class="flex items-center gap-1.5">
|
||
<span class="stat-tag" :class="monthStats.workDays >= settings.fullDays ? 'stat-final' : 'stat-draft'">{{ monthStats.workDays >= settings.fullDays ? '已达标' : monthStats.workDays + '/' + settings.fullDays }}</span>
|
||
<span class="amt" :class="monthStats.fullBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ monthStats.fullBonus > 0 ? '+' + money(monthStats.fullBonus) : '¥0' }}</span>
|
||
</span>
|
||
</div>
|
||
<!-- 查获只是记录:与满勤未达标同一口径,次月初组长审核定稿前按 ¥0 计入应得 -->
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-slate-500">奖惩净额<span class="text-slate-400">{{ monthStats.finalized ? '(审核终稿)' : '(未定稿,不计)' }}</span></span>
|
||
<span class="flex items-center gap-1.5">
|
||
<span class="stat-tag" :class="monthStats.finalized ? 'stat-final' : 'stat-draft'">{{ monthStats.finalized ? '已定稿' : '待次月初审核' }}</span>
|
||
<span class="amt" :class="monthStats.reviewNet > 0 ? 'amt-pos' : (monthStats.reviewNet < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.reviewNet) }}</span>
|
||
</span>
|
||
</div>
|
||
<div class="flex justify-between items-center pt-2 border-t border-slate-100">
|
||
<span class="font-semibold text-slate-600">合计应得</span>
|
||
<span class="text-lg font-bold text-indigo-600 font-mono-num">{{ money(earnedTotal) }}</span>
|
||
</div>
|
||
<p v-if="!monthStats.finalized" class="text-[10px] text-slate-400 leading-relaxed pt-1">查获在本月只作记录(当前累计 <b class="text-slate-500 font-mono-num">{{ signed(monthStats.rawReview) }}</b>);须次月初组长审核、扣除漏查与其他扣款后提交定稿才算奖金。未定稿一律按 ¥0 计入应得,与未达 {{ settings.fullDays }} 天不计满勤奖同一个道理。</p>
|
||
</div>
|
||
<!-- 两套口径之间唯一的桥:明说挣得的钱里哪一部分已发、哪一部分要结转。
|
||
没有这一行,用户看到挣得 ≠ 进账就会懿。 -->
|
||
<div class="px-5 py-3 bg-slate-50 border-t border-slate-100">
|
||
<p class="text-[10px] font-bold text-slate-500 mb-1.5 flex items-center gap-1"><svg class="ico text-[12px] text-slate-400"><use href="#i-calendar-clock" /></svg>何时到手</p>
|
||
<div class="space-y-1 text-[11px]">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-slate-500">{{ currentMonth + 1 }}/{{ settings.payDayA }} 上半月工时<span class="text-slate-400">({{ payDayAPassed ? '已发' : '待发' }})</span></span>
|
||
<span class="font-semibold text-slate-700 font-mono-num">{{ money(earnedSplit.paid) }}</span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-slate-500">{{ nextLabel.month + 1 }}/{{ settings.payDayB }} 下半月工时+奖惩+满勤<span class="text-slate-400">(结转)</span></span>
|
||
<span class="font-semibold text-slate-700 font-mono-num">{{ money(earnedSplit.carry) }}</span>
|
||
</div>
|
||
</div>
|
||
<p v-if="earnedFloored" class="mt-1.5 text-[10px] text-rose-600 font-bold flex items-start gap-1"><svg class="ico text-[13px] mt-px shrink-0"><use href="#i-triangle-alert" /></svg><span>处罚已超过下半月工时收入,结转那笔按 ¥0 计,所以两笔相加不等于合计应得</span></p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 【收付制】X月进账:本月卡里实际进的钱,含上月结转。
|
||
每行最左的归属期徽章是关键:不标就会被看成都是本月挣的。 -->
|
||
<div class="card overflow-hidden">
|
||
<div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between">
|
||
<span class="text-[13px] font-bold text-slate-800 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-wallet" /></svg>{{ currentMonth + 1 }}月进账</span>
|
||
<span class="text-[10px] font-normal text-slate-400 font-mono-num">时薪 {{ settings.rate }} 元/h · 实际到账</span>
|
||
</div>
|
||
<div class="divide-y divide-slate-100">
|
||
<div class="px-5 py-3.5">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<div class="min-w-0 flex items-start gap-2">
|
||
<span class="own-badge own-prev mt-px shrink-0">{{ prevLabel.month + 1 }}月</span>
|
||
<div class="min-w-0">
|
||
<p class="text-xs font-semibold text-slate-700">{{ currentMonth + 1 }}/{{ settings.payDayB }}</p>
|
||
<p class="text-[10px] font-normal text-slate-400 mt-0.5">下半月 {{ fmt(prevStats.h2) }}h{{ prevStats.finalized ? ' + 奖惩 ' + signed(prevStats.adjustNet) : '(奖惩未定稿不计)' }}</p>
|
||
</div>
|
||
</div>
|
||
<span class="text-[17px] font-bold text-slate-800 shrink-0 font-mono-num">{{ money(payoutThisB) }}</span>
|
||
</div>
|
||
<!-- 上月奖惩需在本月 1–2 号汇总申报,所以把上月的定稿入口直接挂在这笔进账下面 -->
|
||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||
<span class="stat-tag" :class="prevStats.finalized ? 'stat-final' : 'stat-draft'">
|
||
<svg class="ico text-[11px]"><use :href="prevStats.finalized ? '#i-check' : '#i-hourglass'" /></svg>
|
||
{{ prevLabel.month + 1 }}月奖惩{{ prevStats.finalized ? '已定稿·已计入' : (prevStats.adjustStatus === 'draft' ? '草稿未定稿·不计入' : '待审核·不计入') }}
|
||
</span>
|
||
<button @click="openAdjust(prevLabel.year, prevLabel.month)"
|
||
class="px-3 py-1.5 rounded-xl text-[11px] font-bold active:scale-95 transition flex items-center gap-1.5"
|
||
:class="prevStats.finalized ? 'bg-slate-100 text-slate-600' : 'bg-amber-500 text-white shadow-sm'">
|
||
<svg class="ico text-[13px]"><use href="#i-notebook-pen" /></svg>{{ prevStats.finalized ? '查看/改稿' : '核对修正' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-2 px-5 py-3.5">
|
||
<div class="min-w-0 flex items-start gap-2">
|
||
<span class="own-badge own-cur mt-px shrink-0">{{ currentMonth + 1 }}月</span>
|
||
<div class="min-w-0">
|
||
<p class="text-xs font-semibold text-slate-700">{{ currentMonth + 1 }}/{{ settings.payDayA }}</p>
|
||
<p class="text-[10px] font-normal text-slate-400 mt-0.5">上半月 {{ fmt(monthStats.h1) }}h · 纯工时</p>
|
||
</div>
|
||
</div>
|
||
<span class="text-[17px] font-bold text-slate-800 shrink-0 font-mono-num">{{ money(payoutThisA) }}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between px-5 py-3 bg-slate-50">
|
||
<span class="text-xs font-semibold text-slate-500">合计进账</span>
|
||
<span class="text-lg font-bold text-indigo-600 font-mono-num">{{ money(cashTotal) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 本月工时 -->
|
||
<div class="card px-5 py-4">
|
||
<p class="text-[13px] font-bold text-slate-800 mb-3 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-clock" /></svg>{{ currentMonth + 1 }}月工时</p>
|
||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||
<div class="inset bg-sky-50/70 p-2.5 border border-sky-100">
|
||
<p class="text-[10px] font-normal text-sky-700/80">上半月 1–{{ settings.cutoff }}日</p>
|
||
<p class="text-[15px] font-bold text-sky-700 mt-0.5 font-mono-num">{{ fmt(monthStats.h1) }}h</p>
|
||
<p class="text-[10px] font-normal text-slate-400 font-mono-num">{{ money(monthStats.h1 * settings.rate) }}</p>
|
||
<!-- 归属标注:让「结转」这个概念在页面里反复出现,形成固定词汇 -->
|
||
<p class="text-[9px] font-semibold text-sky-700/60 mt-1">本月{{ settings.payDayA }}号发</p>
|
||
</div>
|
||
<div class="inset bg-violet-50/60 p-2.5 border border-violet-100/70">
|
||
<p class="text-[10px] font-normal text-violet-700/70">下半月 {{ settings.cutoff + 1 }}日–月底</p>
|
||
<p class="text-[15px] font-bold text-violet-700 mt-0.5 font-mono-num">{{ fmt(monthStats.h2) }}h</p>
|
||
<p class="text-[10px] font-normal text-slate-400 font-mono-num">{{ money(monthStats.h2 * settings.rate) }}</p>
|
||
<p class="text-[9px] font-semibold text-violet-700/60 mt-1">结转{{ nextLabel.month + 1 }}月{{ settings.payDayB }}号发</p>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-1.5 text-xs">
|
||
<div class="flex justify-between"><span class="text-slate-500">出勤天数</span><span class="font-semibold text-slate-700 font-mono-num">{{ monthStats.workDays }} 天(白 {{ monthStats.dayShifts }} / 夜 {{ monthStats.nightShifts }})</span></div>
|
||
<div class="flex justify-between"><span class="text-slate-500">休息 / 请假</span><span class="font-semibold text-slate-700 font-mono-num">{{ monthStats.restDays }} / {{ monthStats.leaveDays }} 天</span></div>
|
||
<div class="flex justify-between pt-2 border-t border-slate-100"><span class="font-semibold text-slate-600">工时收入合计</span><span class="font-bold text-slate-800 font-mono-num">{{ money(monthStats.basePay) }}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 本月奖惩(只是记录,未定稿不计金额) -->
|
||
<div class="card px-5 py-4">
|
||
<div class="flex items-center justify-between mb-1">
|
||
<p class="text-[13px] font-bold text-slate-800 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-award" /></svg>{{ currentMonth + 1 }}月奖惩记录</p>
|
||
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="monthStats.finalized ? 'text-emerald-700 bg-emerald-100' : 'text-amber-700 bg-amber-100'">{{ monthStats.finalized ? '已定稿·次月' + settings.payDayB + '号发' : '待次月初组长审核' }}</span>
|
||
</div>
|
||
<p class="text-[10px] font-normal text-slate-400 mb-2.5">下方为日常记录累计,定稿前不计入任何金额口径</p>
|
||
<div class="space-y-1.5 text-xs">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-slate-500">查获记录<span class="text-slate-400">({{ monthStats.foundCount }}件)</span></span>
|
||
<span class="amt" :class="monthStats.foundBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ monthStats.foundBonus > 0 ? '+' + money(monthStats.foundBonus) : '¥0' }}</span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-slate-500">漏查处罚<span class="text-slate-400">({{ monthStats.missedCount }}件)</span></span>
|
||
<span class="amt" :class="monthStats.missedPenalty > 0 ? 'amt-neg' : 'amt-zero'">{{ monthStats.missedPenalty > 0 ? '-' + money(monthStats.missedPenalty) : '¥0' }}</span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-slate-500">其他扣款</span>
|
||
<span class="amt" :class="monthStats.otherPenalties > 0 ? 'amt-neg' : 'amt-zero'">{{ monthStats.otherPenalties > 0 ? '-' + money(monthStats.otherPenalties) : '¥0' }}</span>
|
||
</div>
|
||
|
||
<!-- 待申报小计:这是次月 1–2 号需要汇总上报、并可能被审核改数的部分 -->
|
||
<div class="flex justify-between items-center pt-2 border-t border-slate-100">
|
||
<span class="text-slate-500 flex items-center gap-1.5">
|
||
日常累计
|
||
<span class="text-[10px] text-slate-400">(待申报)</span>
|
||
</span>
|
||
<span class="amt" :class="monthStats.rawReview > 0 ? 'amt-pos' : (monthStats.rawReview < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.rawReview) }}</span>
|
||
</div>
|
||
|
||
<div v-if="monthStats.finalized" class="flex justify-between items-center">
|
||
<span class="text-slate-500 flex items-center gap-1.5">
|
||
审核终稿
|
||
<span v-if="monthStats.reviewDelta !== 0" class="text-[10px]" :class="monthStats.reviewDelta > 0 ? 'text-emerald-600' : 'text-rose-500'">
|
||
{{ monthStats.reviewDelta > 0 ? '上调' : '下调' }}{{ money(Math.abs(monthStats.reviewDelta)) }}
|
||
</span>
|
||
</span>
|
||
<span class="amt" :class="monthStats.reviewAmount > 0 ? 'amt-pos' : (monthStats.reviewAmount < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.reviewAmount) }}</span>
|
||
</div>
|
||
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-slate-500">满勤奖<span class="text-slate-400">{{ monthStats.workDays >= settings.fullDays ? '(' + fmt(monthStats.totalHours) + 'h×' + settings.fullBonusPerHour + ',下月' + (nextLabel.month + 1) + '号发)' : '(未达' + settings.fullDays + '天,不计)' }}</span></span>
|
||
<span class="amt" :class="monthStats.fullBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ monthStats.fullBonus > 0 ? '+' + money(monthStats.fullBonus) : '¥0' }}</span>
|
||
</div>
|
||
|
||
<!-- 本卡内已经列了满勤奖,所以尾行合计必须把两笔已确认的钱加在一起:
|
||
= 满勤奖(达标才计)+ 奖惩净额(定稿才计),正好是次月 payDayB 除工时外多发的部分 -->
|
||
<div class="flex justify-between items-center pt-2 border-t border-slate-100">
|
||
<span class="font-semibold text-slate-600">计入应得合计<span class="text-[10px] text-slate-400">(满勤+奖惩)</span></span>
|
||
<span class="amt text-[12px]" :class="monthStats.adjustNet > 0 ? 'amt-pos' : (monthStats.adjustNet < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.adjustNet) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 修正入口:日常记录通道不变,这里只改「待申报」总额 -->
|
||
<div class="mt-3 pt-2.5 border-t border-slate-100 flex items-center justify-between gap-2">
|
||
<span class="stat-tag" :class="monthStats.finalized ? 'stat-final' : 'stat-draft'">
|
||
<svg class="ico text-[11px]"><use :href="monthStats.finalized ? '#i-check' : '#i-hourglass'" /></svg>
|
||
{{ monthStats.finalized ? '已定稿·已计入' : (monthStats.adjustStatus === 'draft' ? '草稿未定稿·不计入' : '仅记录·不计入') }}
|
||
</span>
|
||
<button @click="openAdjust(currentYear, currentMonth)"
|
||
class="px-3 py-1.5 rounded-xl text-[11px] font-bold active:scale-95 transition flex items-center gap-1.5"
|
||
:class="monthStats.finalized ? 'bg-slate-100 text-slate-600' : 'bg-indigo-600 text-white shadow-sm'">
|
||
<svg class="ico text-[13px]"><use href="#i-notebook-pen" /></svg>{{ monthStats.finalized ? '查看/改稿' : '核对修正' }}
|
||
</button>
|
||
</div>
|
||
<div v-if="penaltyDetails.length" class="mt-3 pt-2.5 border-t border-slate-100 space-y-1">
|
||
<p class="text-[10px] font-bold text-slate-400 mb-1">处罚明细</p>
|
||
<div v-for="d in penaltyDetails" :key="d.day" class="flex justify-between items-center text-[11px]">
|
||
<span class="text-slate-500 truncate pr-2">{{ d.day }}日 {{ d.reason }}</span>
|
||
<span class="amt amt-neg shrink-0">-{{ money(d.amount) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 结转次月:这张卡是上面「何时到手」里结转那一笔的展开明细。
|
||
标题用「结转」而不是「预估」,与看板副标题、工时卡保持同一个词。 -->
|
||
<div class="bg-amber-50/70 rounded-2xl border border-amber-200/70 p-4">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div>
|
||
<p class="text-xs font-bold text-amber-900">结转到 {{ nextLabel.month + 1 }}月{{ settings.payDayB }}日</p>
|
||
<p class="text-[10px] font-normal text-amber-700/60 mt-0.5">{{ currentMonth + 1 }}月总应得里未发的部分 · 与实际到账同额</p>
|
||
</div>
|
||
<span class="text-lg font-black text-amber-700 font-mono-num">{{ money(earnedCarry) }}</span>
|
||
</div>
|
||
<!-- 三行相加 = 次月 payDayB 实际到账(满勤奖也在这天发,必须列进来) -->
|
||
<div class="space-y-1 text-[11px] text-amber-800/80">
|
||
<div class="flex justify-between items-center"><span>{{ currentMonth + 1 }}月下半月工时 {{ fmt(monthStats.h2) }}h</span><span class="font-mono-num font-semibold">{{ money(monthStats.h2 * settings.rate) }}</span></div>
|
||
<div class="flex justify-between items-center"><span>{{ currentMonth + 1 }}月满勤奖</span><span class="amt" :class="monthStats.fullBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ monthStats.fullBonus > 0 ? '+' + money(monthStats.fullBonus) : '¥0' }}</span></div>
|
||
<div class="flex justify-between items-center"><span>{{ currentMonth + 1 }}月奖惩结算{{ monthStats.finalized ? '' : '(未定稿不计)' }}</span><span class="amt" :class="monthStats.reviewNet > 0 ? 'amt-pos' : (monthStats.reviewNet < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(monthStats.reviewNet) }}</span></div>
|
||
<div class="flex justify-between items-center pt-0.5">
|
||
<span class="text-amber-700/70">奖惩状态</span>
|
||
<span class="font-semibold" :class="monthStats.finalized ? 'text-emerald-700' : 'text-amber-700'">{{ monthStats.finalized ? '已定稿' : '仅记录 ' + signed(monthStats.rawReview) + '·待审核' }}</span>
|
||
</div>
|
||
</div>
|
||
<p v-if="earnedFloored" class="mt-2 text-[10px] text-rose-600 font-bold flex items-start gap-1"><svg class="ico text-[13px] mt-px"><use href="#i-triangle-alert" /></svg><span>处罚已超过下半月工时收入,实发按 ¥0 计,差额可能顺延</span></p>
|
||
</div>
|
||
|
||
<!-- 诊断 -->
|
||
<div class="card px-5 py-4">
|
||
<p class="text-xs font-bold text-slate-700 mb-2 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-lightbulb" /></svg>诊断</p>
|
||
<div class="space-y-1.5 text-[11px] leading-relaxed">
|
||
<!-- 空状态:虚线容器 + 淡色图标坑位,点一下就回日历页打卡,比干摆一行灰字有行动指引 -->
|
||
<div v-if="monthStats.workDays === 0" @click="tab = 'calendar'"
|
||
class="empty-state px-4 py-5 flex flex-col items-center gap-2 cursor-pointer active:scale-[.98] transition">
|
||
<span class="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center">
|
||
<svg class="ico text-[20px] text-indigo-400"><use href="#i-notebook-pen" /></svg>
|
||
</span>
|
||
<p class="text-[11px] font-bold text-slate-500">本月还没有打卡记录</p>
|
||
<p class="text-[10px] text-slate-400 text-center leading-relaxed">点这里回日历,选中日期就能记下白班或夜班。<br />记清一天,这里就会算好预估与满勤进度。</p>
|
||
<span class="mt-0.5 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-indigo-600 text-white text-[10px] font-bold">
|
||
<svg class="ico text-[12px]"><use href="#i-calendar-days" /></svg>去打卡
|
||
</span>
|
||
</div>
|
||
<template v-else>
|
||
<p v-if="monthStats.workDays < settings.fullDays" class="text-slate-600">
|
||
距离满勤还差 <b class="text-slate-800">{{ settings.fullDays - monthStats.workDays }} 天</b>。
|
||
<span class="text-slate-400">若达成,满勤奖为<b class="text-emerald-700">{{ money(monthStats.totalHours * settings.fullBonusPerHour) }}</b>(当月总工时 {{ fmt(monthStats.totalHours) }}h × {{ settings.fullBonusPerHour }} 元/h),下月{{ settings.payDayB }}号单独到账,不计入当月工资。</span>
|
||
</p>
|
||
<p v-else class="text-emerald-600 flex items-start gap-1"><svg class="ico text-[13px] mt-px shrink-0"><use href="#i-party-popper" /></svg><span>已达成 {{ settings.fullDays }} 天满勤,满勤奖 <b>+{{ money(monthStats.fullBonus) }}</b> 将于 {{ nextLabel.month + 1 }}月{{ settings.payDayB }}号发放。</span></p>
|
||
<p v-if="monthStats.missedCount > 0" class="text-slate-600">
|
||
本月 {{ monthStats.missedCount }} 次漏查,将扣 <b class="text-rose-600">{{ money(monthStats.missedPenalty) }}</b>,约等于白干 {{ fmt(monthStats.missedPenalty / settings.rate) }} 小时。
|
||
</p>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<button @click="copyReport" class="w-full py-3 bg-slate-800 text-white rounded-2xl text-xs font-bold active:scale-95 transition shadow-sm flex items-center justify-center gap-1.5">
|
||
<svg class="ico text-[15px]"><use :href="copied ? '#i-check' : '#i-copy'" /></svg>{{ copied ? '已复制到剪贴板' : '复制文本报告' }}
|
||
</button>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<!-- ===== 设置抽屉 ===== -->
|
||
<transition name="drawer">
|
||
<div v-if="settingsOpen" class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm" @click.self="closeSettings">
|
||
<div class="w-full max-w-md bg-white rounded-t-3xl shadow-2xl flex flex-col">
|
||
<!-- 抽屉把手 -->
|
||
<div class="pt-2.5 pb-1 flex justify-center shrink-0" @click="closeSettings">
|
||
<div class="w-[54px] h-[5px] rounded-full bg-slate-300"></div>
|
||
</div>
|
||
<!-- 标题 + 关闭 -->
|
||
<div class="flex justify-between items-center px-5 pb-3 shrink-0">
|
||
<span class="text-lg font-bold text-slate-800 flex items-center gap-1.5"><svg class="ico text-[20px] text-indigo-600"><use href="#i-settings" /></svg>设置</span>
|
||
<button @click="closeSettings" class="text-slate-300 hover:text-slate-500 leading-none px-1 pt-0.5"><svg class="ico text-lg"><use href="#i-x" /></svg></button>
|
||
</div>
|
||
<!-- 抽屉内容:可直接滚动 -->
|
||
<div class="overflow-y-auto no-scrollbar max-h-[72vh] px-4 pb-8 pt-1 space-y-3.5">
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-coins" /></svg>薪酬规则</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">基础时薪(元/h)</span>
|
||
<input type="number" step="0.5" min="0" v-model.number="settings.rate" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">满勤天数(天)</span>
|
||
<input type="number" step="1" min="1" max="31" v-model.number="settings.fullDays" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">满勤奖(元/工时)</span>
|
||
<input type="number" step="0.5" min="0" v-model.number="settings.fullBonusPerHour" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
</div>
|
||
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">满勤奖按「当月总工时 × 单价」计算,属于奖励,不并入当月工资。</p>
|
||
</div>
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-shield-alert" /></svg>奖惩标准</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">查获违禁品(元/件)</span>
|
||
<input type="number" step="1" min="0" v-model.number="settings.foundBonus" class="w-24 text-right text-xs font-bold text-emerald-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">漏查违禁品(元/件)</span>
|
||
<input type="number" step="1" min="0" v-model.number="settings.missedPenalty" class="w-24 text-right text-xs font-bold text-rose-600 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-calendar-clock" /></svg>发放周期</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">上半月截止日</span>
|
||
<input type="number" step="1" min="1" max="28" v-model.number="settings.cutoff" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">上半月发放日(当月)</span>
|
||
<input type="number" step="1" min="1" max="31" v-model.number="settings.payDayA" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">下半月发放日(次月)</span>
|
||
<input type="number" step="1" min="1" max="31" v-model.number="settings.payDayB" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
</div>
|
||
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
|
||
1–{{ settings.cutoff }}日工时 → 当月{{ settings.payDayA }}号发;{{ settings.cutoff + 1 }}日–月底工时 + 当月奖惩 → 次月{{ settings.payDayB }}号一起发。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-hourglass" /></svg>班次默认工时</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<div class="px-5 py-3 flex items-center justify-between">
|
||
<span class="text-xs text-slate-600 flex items-center gap-1.5"><svg class="ico text-[14px] text-indigo-500"><use href="#i-clock" /></svg>打卡默认班次</span>
|
||
<div class="flex rounded-xl overflow-hidden border border-slate-200">
|
||
<button @click="settings.defaultShift = 'day'"
|
||
class="px-3 py-1.5 text-[11px] font-bold transition flex items-center gap-1"
|
||
:class="cfg.defaultShift === 'day' ? 'bg-sky-600 text-white' : 'bg-white text-slate-500'">
|
||
<svg class="ico text-[13px]"><use href="#i-sun" /></svg>白班
|
||
</button>
|
||
<button @click="settings.defaultShift = 'night'"
|
||
class="px-3 py-1.5 text-[11px] font-bold transition flex items-center gap-1"
|
||
:class="cfg.defaultShift === 'night' ? 'bg-violet-600 text-white' : 'bg-white text-slate-500'">
|
||
<svg class="ico text-[13px]"><use href="#i-moon" /></svg>夜班
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600 flex items-center gap-1.5"><svg class="ico text-[14px] text-amber-500"><use href="#i-sun" /></svg>白班默认(h)</span>
|
||
<input type="number" step="0.5" min="0" max="24" v-model.number="settings.dayHours" class="w-24 text-right text-xs font-bold text-sky-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500 font-mono-num" />
|
||
</label>
|
||
<label class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600 flex items-center gap-1.5"><svg class="ico text-[14px] text-violet-500"><use href="#i-moon" /></svg>夜班默认(h)</span>
|
||
<input type="number" step="0.5" min="0" max="24" v-model.number="settings.nightHours" class="w-24 text-right text-xs font-bold text-violet-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500 font-mono-num" />
|
||
</label>
|
||
</div>
|
||
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70">夜班跨天,工时全部归入开始打卡的那一天。</p>
|
||
</div>
|
||
|
||
<!-- 云端同步:未登录时也完全可用,登录只是多一层服务器备份 -->
|
||
<div class="card overflow-hidden">
|
||
<div class="px-5 py-3 bg-slate-50 border-b border-slate-100 flex items-center justify-between gap-2">
|
||
<p class="text-xs font-bold text-slate-700 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-cloud" /></svg>云端同步</p>
|
||
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="syncStatusClass">{{ syncStatusText }}</span>
|
||
</div>
|
||
|
||
<!-- 已登录 -->
|
||
<div v-if="sync.user" class="divide-y divide-slate-100">
|
||
<div class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">当前账号</span>
|
||
<span class="text-xs font-bold text-slate-800">{{ sync.user.username }}</span>
|
||
</div>
|
||
<div v-if="syncTimeText" class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">最后同步</span>
|
||
<span class="text-xs font-bold text-slate-800 font-mono-num">{{ syncTimeText }}</span>
|
||
</div>
|
||
<div v-if="sync.lastError" class="px-5 py-2.5 bg-rose-50">
|
||
<p class="text-[10px] text-rose-700 leading-relaxed">{{ sync.lastError }}</p>
|
||
</div>
|
||
<button @click="doSyncNow" :disabled="sync.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-refresh-cw" /></svg>立即同步{{ sync.pending > 0 ? '(待上传 ' + sync.pending + ' 项)' : '' }}</button>
|
||
<button @click="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 py-3 space-y-2">
|
||
<input v-model="loginForm.username" type="text" autocomplete="username" placeholder="用户名(3–32 位字母数字)"
|
||
class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" />
|
||
<input v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="密码(至少 8 位)" @keyup.enter="doLogin"
|
||
class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" />
|
||
<div class="flex gap-2 pt-0.5">
|
||
<button @click="doLogin" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-indigo-600 text-white active:scale-95 transition disabled:opacity-40">登录</button>
|
||
<button v-if="sync.serverUsers === 0" @click="doRegister" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white active:scale-95 transition disabled:opacity-40">创建首个账号</button>
|
||
</div>
|
||
</div>
|
||
<p class="px-5 py-2.5 text-[10px] leading-relaxed bg-slate-50/70"
|
||
:class="sync.serverUsers === null ? 'text-amber-700' : 'text-slate-400'">
|
||
<template v-if="sync.serverUsers === null">没探到同步服务(当前只跑静态页)。数据仍存本机,一切功能正常;要多设备共用需先启动 server/。</template>
|
||
<template v-else-if="sync.serverUsers === 0">服务端还没有账号。创建首个账号后就会自动关闭注册,不会被陌生人开号。</template>
|
||
<template v-else>登录后打卡数据会自动同步到服务器,多设备共用同一份。未登录也能正常记账,只存本机。</template>
|
||
</p>
|
||
<label class="flex items-center justify-between gap-2 px-5 py-3">
|
||
<span class="text-xs text-slate-600 shrink-0">服务器地址</span>
|
||
<input v-model="sync.apiBase" @change="saveApiBase" type="url" inputmode="url" placeholder="留空 = 同源"
|
||
class="min-w-0 flex-1 text-right text-[11px] text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card overflow-hidden">
|
||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-database" /></svg>数据</p>
|
||
<div class="divide-y divide-slate-100">
|
||
<div class="flex items-center justify-between px-5 py-3">
|
||
<span class="text-xs text-slate-600">已存打卡记录</span>
|
||
<span class="text-xs font-bold text-slate-800 font-mono-num">{{ recordCount }} 天</span>
|
||
</div>
|
||
<button @click="exportData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-download" /></svg>导出备份(JSON 文件)</button>
|
||
<button @click="copyData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use :href="dataCopied ? '#i-check' : '#i-copy'" /></svg>{{ dataCopied ? '已复制到剪贴板' : '复制数据到剪贴板' }}</button>
|
||
<label class="flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition cursor-pointer">
|
||
<svg class="ico text-[15px]"><use href="#i-upload" /></svg>导入备份(同日期覆盖)
|
||
<input type="file" accept=".json,application/json" @change="importData" class="hidden" />
|
||
</label>
|
||
<button @click="resetSettings" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-slate-500 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-rotate-ccw" /></svg>恢复默认设置(不动打卡数据)</button>
|
||
<button @click="clearMonth" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-trash-2" /></svg>清空 {{ currentYear }}年{{ currentMonth + 1 }}月记录</button>
|
||
<button @click="clearAll" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-triangle-alert" /></svg>清空全部打卡记录</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="text-center text-[10px] text-slate-400 leading-relaxed pt-1 pb-2">
|
||
<template v-if="sync.user">数据存本机浏览器,并同步到服务器账号 <b class="text-slate-500">{{ sync.user.username }}</b>。<br />换设备登录同一账号即可看到同一份数据。</template>
|
||
<template v-else>数据仅保存在本机浏览器(localStorage)。<br />清除浏览器数据会丢失,建议登录同步或定期导出备份。</template>
|
||
</p>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- ===== 底部 Tab 导航 ===== -->
|
||
<nav class="fixed bottom-0 left-1/2 -translate-x-1/2 w-full max-w-md bg-white/80 border-t border-slate-200/80 flex safe-bottom z-40" style="backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);">
|
||
<button v-for="t in tabs" :key="t.id"
|
||
:class="t.id === 'settings' ? '' : ''"
|
||
@click="t.id === 'settings' ? openSettings() : (tab = t.id)"
|
||
class="flex-1 flex flex-col items-center pt-2 pb-2.5 active:scale-95 transition relative">
|
||
<!-- 选中态指示条:品牌色,贴在顶边 -->
|
||
<span class="absolute top-0 h-[3px] rounded-b-full bg-indigo-600 transition-all duration-300"
|
||
:class="tab === t.id ? 'w-8 opacity-100' : 'w-0 opacity-0'"></span>
|
||
<svg class="ico text-[21px] transition-colors" :class="tab === t.id ? 'text-indigo-600' : 'text-slate-400'"><use :href="'#' + t.icon" /></svg>
|
||
<span class="text-[10px] mt-1 font-bold transition-colors" :class="tab === t.id ? 'text-indigo-600' : 'text-slate-400'">{{ t.label }}</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<!-- ===== 打卡抽屉 ===== -->
|
||
<transition name="drawer">
|
||
<div v-if="isDrawerOpen" class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm" @click.self="isDrawerOpen = false">
|
||
<div class="w-full max-w-md bg-white rounded-t-3xl px-5 pt-5 pb-6 shadow-2xl overflow-y-auto max-h-[88vh] no-scrollbar">
|
||
|
||
<div class="flex justify-between items-start mb-4 pb-3 border-b border-slate-100">
|
||
<div>
|
||
<h3 class="text-base font-bold text-slate-800">{{ currentMonth + 1 }}月{{ selectedDay }}日 打卡</h3>
|
||
</div>
|
||
<button @click="isDrawerOpen = false" class="text-slate-300 hover:text-slate-500 leading-none px-1 pt-0.5"><svg class="ico text-lg"><use href="#i-x" /></svg></button>
|
||
</div>
|
||
|
||
<!-- 发薪日到账横幅:只展示不阻断打卡 -->
|
||
<div v-if="selectedPayInfo" class="mb-4 rounded-2xl px-4 py-3 flex items-center justify-between gap-3"
|
||
:class="selectedPayInfo.kind === 'a' ? 'bg-indigo-50 border border-indigo-100' : 'bg-amber-50 border border-amber-100'">
|
||
<div class="min-w-0">
|
||
<p class="text-[11px] font-bold flex items-center gap-1.5" :class="selectedPayInfo.kind === 'a' ? 'text-indigo-700' : 'text-amber-800'">
|
||
<svg class="ico text-[14px]"><use href="#i-wallet" /></svg>今日到账
|
||
<span v-if="!selectedPayInfo.finalized" class="stat-tag stat-draft">奖惩待定稿</span>
|
||
</p>
|
||
<p class="text-[10px] mt-0.5 truncate" :class="selectedPayInfo.kind === 'a' ? 'text-indigo-500/80' : 'text-amber-700/80'">{{ selectedPayInfo.desc }}</p>
|
||
</div>
|
||
<span class="text-base font-black shrink-0 font-mono-num" :class="selectedPayInfo.kind === 'a' ? 'text-indigo-700' : 'text-amber-700'">{{ money(selectedPayInfo.amount) }}</span>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-3 gap-2 mb-4">
|
||
<button v-for="st in statusOptions" :key="st.id" @click="draftRecord.status = st.id"
|
||
class="py-2.5 rounded-xl font-bold text-xs border-2 transition flex items-center justify-center gap-1.5"
|
||
:class="draftRecord.status === st.id ? 'border-indigo-600 bg-indigo-50 text-indigo-600' : 'border-slate-100 bg-slate-50 text-slate-400'">
|
||
<svg class="ico text-[15px]"><use :href="'#' + st.icon" /></svg>{{ st.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="draftRecord.status === 'work'" class="space-y-3.5">
|
||
<div class="flex space-x-2">
|
||
<button @click="selectShift('day')"
|
||
class="flex-1 py-2.5 rounded-xl text-xs font-bold border transition flex items-center justify-center gap-1.5"
|
||
:class="draftRecord.shift_type === 'day' ? 'bg-sky-600 text-white border-sky-600' : 'bg-slate-50 text-slate-500 border-slate-200'">
|
||
<svg class="ico text-[15px]"><use href="#i-sun" /></svg>白班 <span class="font-mono-num">{{ cfg.dayHours }}h</span>
|
||
</button>
|
||
<button @click="selectShift('night')"
|
||
class="flex-1 py-2.5 rounded-xl text-xs font-bold border transition flex items-center justify-center gap-1.5"
|
||
:class="draftRecord.shift_type === 'night' ? 'bg-violet-600 text-white border-violet-600' : 'bg-slate-50 text-slate-500 border-slate-200'">
|
||
<svg class="ico text-[15px]"><use href="#i-moon" /></svg>夜班 <span class="font-mono-num">{{ cfg.nightHours }}h</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="p-3.5 bg-slate-50 rounded-2xl border border-slate-200/70">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<span class="text-[11px] font-bold text-slate-500">结算工时</span>
|
||
<span class="text-xs font-bold text-indigo-600 bg-white px-2 py-0.5 rounded-lg border border-indigo-100 font-mono-num">{{ draftRecord.hours }} h</span>
|
||
</div>
|
||
<div class="flex overflow-x-auto space-x-1.5 no-scrollbar pb-0.5">
|
||
<button v-for="h in hourOptions" :key="h" @click="draftRecord.hours = h"
|
||
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold shrink-0 transition tabular-nums"
|
||
:class="draftRecord.hours === h ? 'bg-indigo-600 text-white' : 'bg-white text-slate-500 border border-slate-200'">
|
||
{{ h }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-amber-50/60 p-3.5 rounded-2xl border border-amber-100">
|
||
<p class="text-[10px] text-amber-800/80 font-bold mb-2.5 text-center">以下只是记录,需次月初组长审核定稿后才算金额</p>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="text-center">
|
||
<p class="text-[10px] font-bold text-emerald-600">查获 +{{ settings.foundBonus }}/件</p>
|
||
<div class="flex justify-center items-center space-x-2 mt-1.5">
|
||
<button @click="draftRecord.contraband_found = Math.max(0, draftRecord.contraband_found - 1)" class="w-7 h-7 bg-white rounded-full shadow-sm border border-slate-200 active:scale-90 flex items-center justify-center text-slate-500"><svg class="ico text-sm"><use href="#i-minus" /></svg></button>
|
||
<span class="font-bold text-sm text-slate-700 w-5 font-mono-num">{{ draftRecord.contraband_found }}</span>
|
||
<button @click="draftRecord.contraband_found++" class="w-7 h-7 bg-emerald-600 text-white rounded-full shadow-sm active:scale-90 flex items-center justify-center"><svg class="ico text-sm"><use href="#i-plus" /></svg></button>
|
||
</div>
|
||
</div>
|
||
<div class="text-center">
|
||
<p class="text-[10px] font-bold text-rose-600">漏查 −{{ settings.missedPenalty }}/件</p>
|
||
<div class="flex justify-center items-center space-x-2 mt-1.5">
|
||
<button @click="draftRecord.contraband_missed = Math.max(0, draftRecord.contraband_missed - 1)" class="w-7 h-7 bg-white rounded-full shadow-sm border border-slate-200 active:scale-90 flex items-center justify-center text-slate-500"><svg class="ico text-sm"><use href="#i-minus" /></svg></button>
|
||
<span class="font-bold text-sm text-slate-700 w-5 font-mono-num">{{ draftRecord.contraband_missed }}</span>
|
||
<button @click="draftRecord.contraband_missed++" class="w-7 h-7 bg-rose-500 text-white rounded-full shadow-sm active:scale-90 flex items-center justify-center"><svg class="ico text-sm"><use href="#i-plus" /></svg></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center space-x-2 mt-3">
|
||
<input type="number" min="0" step="1" v-model.number="draftRecord.other_penalty" placeholder="其他扣款"
|
||
class="w-24 bg-white border border-slate-200 rounded-lg px-2 py-1.5 text-[11px] text-slate-700 outline-none focus:border-indigo-500 font-mono-num" />
|
||
<input type="text" v-model="draftRecord.penalty_reason" placeholder="处罚原因(选填)"
|
||
class="flex-1 bg-white border border-slate-200 rounded-lg px-2 py-1.5 text-[11px] text-slate-700 outline-none focus:border-indigo-500" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-5 flex space-x-2.5">
|
||
<button @click="deleteRecord" class="px-4 py-3 bg-slate-100 text-slate-500 rounded-xl text-xs font-bold active:scale-95 transition flex items-center gap-1.5"><svg class="ico text-sm"><use href="#i-trash-2" /></svg>清除</button>
|
||
<button @click="saveRecord" class="flex-1 py-3 bg-indigo-600 text-white rounded-xl text-sm font-bold shadow-md active:scale-95 transition flex items-center justify-center gap-1.5"><svg class="ico text-base"><use href="#i-check" /></svg>保存打卡</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- ===== 奖惩核对/修正抽屉 ===== -->
|
||
<transition name="drawer">
|
||
<div v-if="adjustOpen" class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm" @click.self="closeAdjust">
|
||
<div class="w-full max-w-md bg-white rounded-t-3xl px-5 pt-5 pb-6 shadow-2xl overflow-y-auto max-h-[88vh] no-scrollbar">
|
||
|
||
<div class="flex justify-between items-start mb-4 pb-3 border-b border-slate-100">
|
||
<div>
|
||
<h3 class="text-base font-bold text-slate-800 flex items-center gap-1.5">
|
||
<svg class="ico text-[18px] text-indigo-600"><use href="#i-shield-alert" /></svg>
|
||
{{ adjustTarget.year }}年{{ adjustTarget.month + 1 }}月 奖惩核对
|
||
</h3>
|
||
<p class="text-[10px] text-slate-400 mt-0.5">次月1–2号汇总申报,以审核终稿为准 · 随{{ settings.payDayB }}号工资发放</p>
|
||
</div>
|
||
<button @click="closeAdjust" class="text-slate-300 hover:text-slate-500 leading-none px-1 pt-0.5"><svg class="ico text-lg"><use href="#i-x" /></svg></button>
|
||
</div>
|
||
|
||
<!-- 日常累计(只读) -->
|
||
<div class="inset bg-slate-50 border border-slate-200/70 p-3.5 mb-3">
|
||
<p class="text-[11px] font-bold text-slate-500 mb-2 flex items-center gap-1.5">
|
||
<svg class="ico text-[13px] text-slate-400"><use href="#i-notebook-pen" /></svg>日常打卡累计(只读)
|
||
</p>
|
||
<div class="space-y-1 text-[11px]">
|
||
<div class="flex justify-between"><span class="text-slate-500">查获奖励({{ adjustStats.foundCount }}件)</span><span class="amt" :class="adjustStats.foundBonus > 0 ? 'amt-pos' : 'amt-zero'">{{ adjustStats.foundBonus > 0 ? '+' + money(adjustStats.foundBonus) : '¥0' }}</span></div>
|
||
<div class="flex justify-between"><span class="text-slate-500">漏查处罚({{ adjustStats.missedCount }}件)</span><span class="amt" :class="adjustStats.missedPenalty > 0 ? 'amt-neg' : 'amt-zero'">{{ adjustStats.missedPenalty > 0 ? '-' + money(adjustStats.missedPenalty) : '¥0' }}</span></div>
|
||
<div class="flex justify-between"><span class="text-slate-500">其他扣款</span><span class="amt" :class="adjustStats.otherPenalties > 0 ? 'amt-neg' : 'amt-zero'">{{ adjustStats.otherPenalties > 0 ? '-' + money(adjustStats.otherPenalties) : '¥0' }}</span></div>
|
||
<div class="flex justify-between pt-1.5 mt-1 border-t border-slate-200/80">
|
||
<span class="font-bold text-slate-600">累计小计</span>
|
||
<span class="amt text-[12px]" :class="adjustStats.rawReview > 0 ? 'amt-pos' : (adjustStats.rawReview < 0 ? 'amt-neg' : 'amt-zero')">{{ signed(adjustStats.rawReview) }}</span>
|
||
</div>
|
||
</div>
|
||
<p class="text-[10px] text-slate-400 mt-2 leading-relaxed">满勤奖由出勤天数客观判定,不在核对范围内(当前 {{ adjustStats.workDays }}/{{ settings.fullDays }} 天,{{ adjustStats.fullBonus > 0 ? '+' + money(adjustStats.fullBonus) : '未达标' }})。</p>
|
||
</div>
|
||
|
||
<!-- 审核终稿(可编辑) -->
|
||
<div class="inset bg-indigo-50/60 border border-indigo-100 p-3.5 mb-3">
|
||
<p class="text-[11px] font-bold text-indigo-700 mb-2 flex items-center gap-1.5">
|
||
<svg class="ico text-[13px]"><use href="#i-award" /></svg>审核终稿金额
|
||
</p>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm font-bold text-slate-500">¥</span>
|
||
<input type="number" step="0.5" v-model.number="adjustDraft.finalAmount" placeholder="填写最终审核金额"
|
||
class="flex-1 bg-white border border-indigo-200 rounded-xl px-3 py-2 text-sm font-bold text-slate-800 outline-none focus:border-indigo-500 font-mono-num" />
|
||
<button @click="adjustDraft.finalAmount = Math.round(adjustStats.rawReview * 10) / 10"
|
||
class="px-2.5 py-2 bg-white border border-indigo-200 rounded-xl text-[10px] font-bold text-indigo-600 active:scale-95 transition shrink-0">按累计</button>
|
||
</div>
|
||
<p class="text-[10px] text-indigo-500/80 mt-1.5">奖多于罚填正数,罚多于奖填负数。例:累计 {{ signed(adjustStats.rawReview) }},审核只认 80 就填 80。</p>
|
||
|
||
<div v-if="adjustDelta !== null && adjustDelta !== 0" class="mt-2.5 pt-2.5 border-t border-indigo-100 flex justify-between items-center text-[11px]">
|
||
<span class="text-slate-500">与累计差额</span>
|
||
<span class="amt" :class="adjustDelta > 0 ? 'amt-pos' : 'amt-neg'">{{ adjustDelta > 0 ? '上调 +' : '下调 -' }}{{ money(Math.abs(adjustDelta)) }}</span>
|
||
</div>
|
||
|
||
<input type="text" v-model="adjustDraft.note" maxlength="60" placeholder="备注(如:与班长核对后确认)"
|
||
class="w-full mt-2.5 bg-white border border-indigo-200 rounded-xl px-3 py-2 text-[11px] text-slate-700 outline-none focus:border-indigo-500" />
|
||
</div>
|
||
|
||
<!-- 生效后的口径预览 -->
|
||
<div class="inset bg-amber-50/70 border border-amber-100 p-3.5 mb-4">
|
||
<div class="flex justify-between items-center text-[11px]">
|
||
<span class="text-amber-800/80">定稿后奖惩净额(含满勤奖)</span>
|
||
<span class="amt text-[12px]" :class="(Number(adjustDraft.finalAmount) || 0) + adjustStats.fullBonus > 0 ? 'amt-pos' : ((Number(adjustDraft.finalAmount) || 0) + adjustStats.fullBonus < 0 ? 'amt-neg' : 'amt-zero')">{{ signed((Number(adjustDraft.finalAmount) || 0) + adjustStats.fullBonus) }}</span>
|
||
</div>
|
||
<p class="text-[10px] text-amber-700/70 mt-1">该金额将并入 {{ adjustTarget.month === 11 ? adjustTarget.year + 1 : adjustTarget.year }}年{{ adjustTarget.month === 11 ? 1 : adjustTarget.month + 2 }}月{{ settings.payDayB }}号发放。</p>
|
||
</div>
|
||
|
||
<div v-if="adjustStats.finalized" class="flex items-center gap-1.5 mb-3 text-[10px] text-emerald-700">
|
||
<svg class="ico text-[13px]"><use href="#i-check" /></svg>
|
||
<span>已于 {{ (adjustStats.finalizedAt || '').slice(0, 16).replace('T', ' ') }} 定稿</span>
|
||
</div>
|
||
|
||
<div class="flex space-x-2.5">
|
||
<button v-if="adjustStats.adjustStatus" @click="unfinalizeAdjust"
|
||
class="px-4 py-3 bg-slate-100 text-slate-500 rounded-xl text-xs font-bold active:scale-95 transition flex items-center gap-1.5">
|
||
<svg class="ico text-sm"><use href="#i-rotate-ccw" /></svg>撤销
|
||
</button>
|
||
<button @click="saveAdjustDraft"
|
||
class="px-4 py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-bold active:scale-95 transition">存草稿</button>
|
||
<button @click="finalizeAdjust"
|
||
class="flex-1 py-3 bg-indigo-600 text-white rounded-xl text-sm font-bold shadow-md active:scale-95 transition flex items-center justify-center gap-1.5">
|
||
<svg class="ico text-base"><use href="#i-check" /></svg>确认定稿
|
||
</button>
|
||
</div>
|
||
<p class="text-center text-[10px] text-slate-400 mt-2.5">存草稿不改变发放金额;定稿后按终稿金额计算。日常打卡记录不受影响。</p>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- ===== 首次登录合并:本机与服务器同一天内容不同时,让用户自己选 ===== -->
|
||
<transition name="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>
|
||
|
||
<div id="boot-fail" hidden class="max-w-md mx-auto min-h-screen bg-slate-50 flex items-center justify-center p-8">
|
||
<div class="card p-6 text-center w-full">
|
||
<p id="boot-title" class="text-sm font-bold text-slate-800">页面没能加载完成</p>
|
||
<p id="boot-desc" class="text-xs text-slate-500 mt-2 leading-relaxed">
|
||
渲染引擎 <code class="font-mono-num">vue.global.prod.js</code> 没能就位,界面画不出来。<br />
|
||
你的打卡数据存在这台设备本机,跟这次失败无关,一条都不会丢。
|
||
</p>
|
||
<button id="boot-retry" type="button" class="mt-4 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>
|
||
<p id="boot-detail" class="text-[11px] text-slate-400 mt-3 leading-relaxed break-all"></p>
|
||
</div>
|
||
</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 card = document.getElementById('boot-fail');
|
||
const title = document.getElementById('boot-title');
|
||
const desc = document.getElementById('boot-desc');
|
||
const btn = document.getElementById('boot-retry');
|
||
const detail = document.getElementById('boot-detail');
|
||
|
||
document.getElementById('app').remove();
|
||
|
||
// 自动重试的次数上限必须跨 reload 记账。
|
||
// 原因:取回成功后只能靠 location.reload() 才能真正把 Vue 跑起来,而 reload 会让
|
||
// 这段代码重新执行。若不记次数,一旦遇到「能取回但仍起不来」的情况就成了无限刷新循环 ——
|
||
// 那比一张错误卡片糟糕得多,用户连「导出备份」都点不到,数据看得见摸不着。
|
||
//
|
||
// 用 sessionStorage 而不是 localStorage:关掉标签页就归零。
|
||
// 上周的失败不该让今天这次连一次自动重试都不给。
|
||
const KEY = 'wht_boot_retry';
|
||
const MAX_AUTO = 2;
|
||
const spent = Number(sessionStorage.getItem(KEY) || 0);
|
||
const autoLeft = Math.max(0, MAX_AUTO - spent);
|
||
let round = 0;
|
||
|
||
// 文案按实际缺的那几项拼,不写死“渲染引擎”——缺的是哪个就说哪个。
|
||
const MISS_TEXT = bootMissing
|
||
.map((d) => d.label + ' <code class="font-mono-num">' + d.src.split('/').pop() + '</code>')
|
||
.join('、');
|
||
const FAIL_DESC = MISS_TEXT + ' 没能就位,界面画不出来。<br />'
|
||
+ '你的打卡数据存在这台设备本机,跟这次失败无关,一条都不会丢。';
|
||
|
||
function showRetrying() {
|
||
title.textContent = '正在加载…';
|
||
desc.innerHTML = MISS_TEXT + ' 还没到位,正在自动重新获取。<br />你的打卡数据存在本机,一条都不会丢。';
|
||
btn.hidden = true;
|
||
}
|
||
|
||
function showFailure(msg) {
|
||
title.textContent = '页面没能加载完成';
|
||
desc.innerHTML = FAIL_DESC;
|
||
btn.hidden = false;
|
||
btn.disabled = false;
|
||
btn.textContent = '再试一次';
|
||
detail.textContent = msg;
|
||
}
|
||
|
||
/** 把缺的依赖逐个取一遍并验完整性,成功返回总字节数。 */
|
||
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;
|
||
}
|
||
|
||
async function tryLoad(auto) {
|
||
if (auto) {
|
||
detail.textContent = '正在重新获取…' + (round > 0 ? '(第 ' + (round + 1) + ' 次)' : '');
|
||
} else {
|
||
btn.disabled = true;
|
||
btn.textContent = '正在重新获取…';
|
||
detail.textContent = '';
|
||
}
|
||
try {
|
||
const n = await refetch();
|
||
// 只在确实要 reload 时记账:这个计数器要挡的就是刷新循环。
|
||
sessionStorage.setItem(KEY, String(spent + 1));
|
||
detail.textContent = '已取回 ' + n + ' 字节,正在重新载入…';
|
||
location.reload();
|
||
} catch (e) {
|
||
const msg = (e && e.message) || String(e);
|
||
if (auto && ++round < autoLeft) {
|
||
// 退避:链路刚断时立刻重试大概率还是断的,给它一点恢复时间。
|
||
detail.textContent = msg + ',稍后自动重试…';
|
||
setTimeout(() => tryLoad(true), 800 * round);
|
||
return;
|
||
}
|
||
showFailure('重试失败:' + msg
|
||
+ (round > 0 ? '(已自动重试 ' + round + ' 次)' : '')
|
||
+ '。若一直失败,换个网络(关掉代理/热点)或停用浏览器插件再试。');
|
||
}
|
||
}
|
||
|
||
btn.addEventListener('click', () => tryLoad(false));
|
||
|
||
if (autoLeft > 0) {
|
||
showRetrying();
|
||
card.hidden = false;
|
||
tryLoad(true);
|
||
} else {
|
||
// 自动重试额度已在本次会话用尽,说明不是偶发丢包,交给用户决定。
|
||
showFailure('已自动重试多次仍未成功。换个网络(关掉代理/热点)或停用浏览器插件再试。');
|
||
card.hidden = false;
|
||
}
|
||
|
||
throw new Error('启动依赖缺失(' + bootMissing.map((d) => d.name).join(', ') + '),已切到 boot-fail 流程');
|
||
}
|
||
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');
|
||
// 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 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; };
|
||
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, apiBase: '', busy: false, hold: null
|
||
});
|
||
const syncPanelOpen = ref(false);
|
||
const loginForm = ref({ username: '', password: '' });
|
||
|
||
// 首次登录合并:本机与服务器同一天内容不同时,让用户选哪份赢
|
||
const mergeOpen = ref(false);
|
||
const mergeInfo = ref(null);
|
||
|
||
/** 把一条记录压成一行人能读的短描述,用于冲突对照 */
|
||
const describeRecord = (r) => {
|
||
if (!r) return '(无)';
|
||
if (r.status === 'rest') return '休息';
|
||
if (r.status === 'leave') return '请假';
|
||
const bits = [(r.shift_type === 'night' ? '夜班' : '白班') + ' ' + fmt(r.hours) + 'h'];
|
||
if (r.contraband_found > 0) bits.push('查获' + r.contraband_found);
|
||
if (r.contraband_missed > 0) bits.push('漏查' + r.contraband_missed);
|
||
if (Number(r.other_penalty) > 0) bits.push('扣' + money(r.other_penalty));
|
||
return bits.join(' · ');
|
||
};
|
||
|
||
const describeAdjust = (a) => {
|
||
if (!a) return '(无)';
|
||
const st = a.status === 'final' ? '已定稿' : '草稿';
|
||
const amt = (a.finalAmount === null || a.finalAmount === undefined || a.finalAmount === '')
|
||
? '未填金额' : signed(a.finalAmount);
|
||
return st + ' ' + amt;
|
||
};
|
||
|
||
const mergeRows = computed(() => {
|
||
const d = mergeInfo.value && mergeInfo.value.diff;
|
||
if (!d) return [];
|
||
const rows = [];
|
||
for (const c of d.conflictDetail || []) {
|
||
rows.push({ key: c.key, label: c.key.slice(5).replace('-', '月') + '日',
|
||
local: describeRecord(c.local), remote: describeRecord(c.remote) });
|
||
}
|
||
for (const c of d.adjustConflictDetail || []) {
|
||
rows.push({ key: c.key, label: c.key.slice(5) + '月奖惩',
|
||
local: describeAdjust(c.local), remote: describeAdjust(c.remote) });
|
||
}
|
||
for (const s of d.settingsDiff || []) {
|
||
rows.push({ key: 'set:' + s.key, label: SETTING_LABELS[s.key] || ('设置 ' + s.key),
|
||
local: String(s.local), remote: String(s.remote) });
|
||
}
|
||
return rows;
|
||
});
|
||
|
||
const syncStatusText = computed(() => {
|
||
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(() => {});
|
||
};
|
||
|
||
// ===== 载入与持久化 =====
|
||
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.hold = s.hold;
|
||
});
|
||
sync.apiBase = window.WHTSync.getApiBase() === location.origin ? '' : window.WHTSync.getApiBase();
|
||
// 探一下服务端在不在、有没有账号,决定设置页该显示「创建账号」还是「登录」
|
||
window.WHTSync.health().catch(() => {});
|
||
}
|
||
});
|
||
|
||
watch(settings, () => {
|
||
localStorage.setItem('work_settings', JSON.stringify({ ...settings }));
|
||
if (!suppressSync && window.WHTSync) window.WHTSync.markDirty('settings');
|
||
}, { deep: true });
|
||
|
||
return {
|
||
settings, cfg, records, recordCount,
|
||
tab, tabs, statusOptions,
|
||
currentYear, currentMonth, selectedDay, daysInMonth, firstDayOfWeek,
|
||
isDrawerOpen, settingsOpen, legendOpen, draftRecord, hourOptions,
|
||
HOUR_MIN, HOUR_MAX, HOUR_STEP, hInt, hFrac, clampHours,
|
||
copied, dataCopied, toast,
|
||
monthStats, prevStats, prevLabel, nextLabel,
|
||
payoutThisA, payoutThisB, payoutNextB, penaltyDetails,
|
||
earnedTotal, cashTotal, earnedSplit, earnedFloored, payDayAPassed,
|
||
fullDaysLeft,
|
||
fmt, money, signed, moneyParts, boardMain, buildReport, applyImport,
|
||
isToday, isCurrentMonth, getRecord, hasPenalty, cellClass, isCutEdge,
|
||
payDayKind, selectedPayInfo,
|
||
monthAdjust, adjustOpen, adjustTarget, adjustDraft, adjustStats, adjustDelta,
|
||
openAdjust, closeAdjust, saveAdjustDraft, finalizeAdjust, unfinalizeAdjust,
|
||
changeMonth, goToday, openDayDrawer, selectShift,
|
||
openSettings, closeSettings,
|
||
sync, syncPanelOpen, loginForm, syncStatusText, syncStatusClass, syncTimeText,
|
||
mergeOpen, mergeInfo, mergeRows, resolveMerge, deferMerge, reopenMerge,
|
||
doLogin, doRegister, doLogout, doSyncNow, doPullAll, doPushAll, saveApiBase,
|
||
saveRecord, deleteRecord, copyReport,
|
||
exportData, copyData, importData, resetSettings, clearMonth, clearAll
|
||
};
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|