baseline: 薪酬逻辑收口到 shared/payroll.js 后的当前全量状态

这是第一个包含完整工作区的提交,也是后续「拆 SFC / 上 Vite」的回滚基线。

此前的提交均为 index.html 单文件历史快照,由 .bak 文件重建。

当前状态(提交前已实测):
- index.html 内联薪酬算法已删除,改调 WHTPayroll
- shared/payroll.js 为唯一薪酬实现,浏览器与 Node 共用
- 对外接口未变:aggregateMonth / isCutEdge / payDayKind 等保留同名薄包装

未入库内容见 .gitignore:依赖、Chrome 验证 profile、验证截图、
运行时 sqlite 数据与日志、时间戳备份、.trash。
This commit is contained in:
OpenClaw Agent
2026-09-05 14:35:00 +08:00
parent 7d48c4ac65
commit 2329955e32
94 changed files with 12770 additions and 237 deletions
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+1022
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"name": "wht-css-build",
"private": true,
"description": "只用于把 Tailwind CDN 的运行时编译换成预生成静态 CSS,不参与运行时",
"devDependencies": {
"tailwindcss": "3.4.17"
}
}
+11
View File
@@ -0,0 +1,11 @@
/** Tailwind 构建配置 —— 只在开发机跑,产物是 vendor/tailwind.css,运行时不需要它
* content 指向 index.html:JIT 扫描其中出现的 class 字面量,只生成用到的那些。
* 已确认 index.html 里没有字符串拼接/模板字符串生成的 class,所以静态扫描是完备的。
*/
module.exports = {
content: ['../index.html'],
theme: {
extend: {}
},
plugins: []
};
+29
View File
@@ -0,0 +1,29 @@
# ── 依赖(可由 lockfile 重装)────────────────────────────────
node_modules/
# ── 浏览器验证脚本的一次性 profile ──────────────────────────
# 约 36M,且 .chrome-login 里带着实测登录态,不该进版本库
png/.chrome-*/
# ── 验证截图:一次性证据,重跑对应 png/verify_*.js 即可再生 ──
png/*.png
# ── 运行时数据与日志 ────────────────────────────────────────
# work-hours.db 是真实打卡数据,不进版本库:
# 1) sqlite 二进制 + WAL 每次写都产生整块 diff,仓库会迅速膨胀
# 2) 版本库不是数据备份手段,数据需要独立的导出/备份流程
server/data/*.db
server/data/*.db-shm
server/data/*.db-wal
server/data/*.log
# ── 时间戳备份:已重建为 main 上的历史提交,git 接管后不再入库 ──
# 原始 .bak.* 文件保留在磁盘上,未删除
*.bak.*
# ── 回收站 ──────────────────────────────────────────────────
.trash/
# ── 杂项 ────────────────────────────────────────────────────
.DS_Store
*.tmp
+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-award"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 453 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-briefcase"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 400 B

+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-calendar-clock"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 505 B

+24
View File
@@ -0,0 +1,24 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-calendar-days"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 569 B

+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-chart-column"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M18 17V9" />
<path d="M13 17V5" />
<path d="M8 17v-3" />
</svg>

After

Width:  |  Height:  |  Size: 404 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-check"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-chevron-left"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-chevron-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-clock"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="10" />
</svg>

After

Width:  |  Height:  |  Size: 348 B

+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-coffee"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 457 B

+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-coins"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 432 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-copy"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-database"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-download"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 398 B

+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-hourglass"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 513 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-lightbulb"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 452 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-minus"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-moon"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 410 B

+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-notebook-pen"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 603 B

+23
View File
@@ -0,0 +1,23 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-party-popper"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 818 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-plus"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>

After

Width:  |  Height:  |  Size: 332 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-rotate-ccw"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 379 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-settings"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 666 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-shield-alert"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 519 B

+23
View File
@@ -0,0 +1,23 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-sun"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 560 B

+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-trash-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 468 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-triangle-alert"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 431 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-upload"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-wallet"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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" />
</svg>

After

Width:  |  Height:  |  Size: 465 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.542.0 - ISC -->
<svg
class="lucide lucide-x"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>

After

Width:  |  Height:  |  Size: 333 B

+165
View File
@@ -0,0 +1,165 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1520" viewBox="0 0 1080 1520" font-family="'PingFang SC','Microsoft YaHei',sans-serif">
<defs>
<linearGradient id="hd" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1E293B"/>
<stop offset="0.56" stop-color="#312E81"/>
<stop offset="1" stop-color="#4F46E5"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="18" flood-color="#312E81" flood-opacity="0.30"/>
</filter>
</defs>
<!-- 背景 -->
<rect width="1080" height="1520" fill="#F1F5F9"/>
<!-- 标题 -->
<text x="60" y="80" font-size="42" font-weight="800" fill="#0F172A">设置 · 底部抽屉面板(Bottom Drawer)设计</text>
<text x="60" y="122" font-size="24" fill="#64748B">齿轮触发 → 底部滑出 → 五个设置分区;遮罩点击/下滑关闭。替代"齿轮直达设置页"。</text>
<line x1="60" y1="146" x2="1020" y2="146" stroke="#CBD5E1" stroke-width="2"/>
<!-- ============ 左:滑出前的页面(日历页参考) ============ -->
<g transform="translate(60,220)">
<rect width="440" height="900" rx="30" fill="#FFFFFF" stroke="#E2E8F0" stroke-width="2"/>
<!-- 顶部渐变看板 -->
<rect x="8" y="8" width="424" height="150" rx="24" fill="url(#hd)"/>
<text x="40" y="60" font-size="22" fill="#C7D2FE">2026年9月</text>
<text x="40" y="105" font-size="34" font-weight="800" fill="#fff">¥ 6,180.0</text>
<text x="40" y="132" font-size="16" fill="#C7D2FE">10日 ¥2,940.0 · 22日 ¥3,240.0</text>
<!-- 齿轮按钮 -->
<circle cx="392" cy="48" r="18" fill="rgba(255,255,255,.16)"/>
<text x="376" y="57" font-size="24" fill="#fff">⚙</text>
<text x="300" y="92" font-size="13" text-anchor="middle" fill="#A5B4FC" opacity="0.0">.</text>
<!-- 胶囊标签行 -->
<g font-size="15">
<rect x="40" y="138" width="86" height="28" rx="14" fill="rgba(255,255,255,.14)" stroke="rgba(255,255,255,.16)"/>
<text x="56" y="158" fill="#E2E8F0">出勤 16</text>
<rect x="134" y="138" width="86" height="28" rx="14" fill="rgba(255,255,255,.14)" stroke="rgba(255,255,255,.16)"/>
<text x="150" y="158" fill="#E2E8F0">工时 124h</text>
<rect x="228" y="138" width="104" height="28" rx="14" fill="rgba(255,255,255,.14)" stroke="rgba(255,255,255,.16)"/>
<text x="244" y="158" fill="#FDE68A">收入 ¥5,000</text>
</g>
<!-- 周日头 -->
<g transform="translate(36,200)" font-size="15" fill="#94A3B8">
<text x="0">日</text><text x="47">一</text><text x="94">二</text><text x="141">三</text>
<text x="188">四</text><text x="235">五</text><text x="282">六</text>
</g>
<!-- 一横行日历格(示意 4 格) -->
<g transform="translate(36,232)">
<!-- 格1 空 -->
<rect width="56" height="62" rx="10" fill="#fff" stroke="#E2E8F0"/>
<text x="8" y="18" font-size="16" fill="#1F2937">1</text>
<text x="24" y="52" font-size="12" fill="#CBD5E1">—</text>
<!-- 格2 白班 -->
<rect x="70" width="56" height="62" rx="10" fill="#FFF7ED" stroke="#FED7AA"/>
<text x="78" y="18" font-size="16" fill="#1F2937">2</text>
<rect x="80" y="2" width="6" height="6" rx="3" fill="#10B981"/>
<!-- 白班胶囊 -->
<rect x="76" y="34" width="46" height="20" rx="10" fill="#FFEDD5"/>
<text x="82" y="48" font-size="13" font-weight="700" fill="#EA580C">白 8h</text>
<!-- 格3 夜班 -->
<rect x="140" width="56" height="62" rx="10" fill="#EEF2FF" stroke="#C7D2FE"/>
<text x="148" y="18" font-size="16" fill="#1F2937">3</text>
<rect x="150" y="2" width="6" height="6" rx="3" fill="#F43F5E"/>
<!-- 夜班胶囊 -->
<rect x="146" y="34" width="46" height="20" rx="10" fill="#E0E7FF"/>
<text x="152" y="48" font-size="13" font-weight="700" fill="#4F46E5">夜 10h</text>
<!-- 格4 休 -->
<rect x="210" width="56" height="62" rx="10" fill="#F1F5F9" stroke="#E2E8F0"/>
<text x="218" y="18" font-size="16" fill="#1F2937">4</text>
<text x="234" y="48" font-size="13" fill="#9CA3AF">休</text>
</g>
<text x="36" y="352" font-size="16" fill="#94A3B8">▲ 滑出前:顶部看板 + 齿轮 + 胶囊日历格</text>
<!-- 底部 Tab -->
<g transform="translate(0,810)">
<rect width="440" height="66" rx="0" fill="#fff" stroke="#E2E8F0"/>
<g text-anchor="middle" font-size="15" fill="#94A3B8">
<text x="80" y="30" font-size="24">📅</text><text x="80" y="50">打卡</text>
<text x="220" y="30" font-size="24">📊</text><text x="220" y="50">汇总</text>
<text x="360" y="30" font-size="24" fill="#4F46E5">⚙</text><text x="360" y="50" fill="#4F46E5">设置</text>
</g>
<rect x="332" y="0" width="56" height="3" fill="#4F46E5"/>
</g>
<text x="36" y="360" font-size="16" fill="#64748B">底部 Tab 保留设置项(抽屉打开在 Tab 之上)</text>
</g>
<!-- 分隔箭头 -->
<text x="545" y="580" font-size="60" fill="#94A3B8">→</text>
<!-- ============ 右:抽屉展开状态 ============ -->
<g transform="translate(590,190)">
<!-- 遮罩 -->
<rect width="430" height="940" rx="30" fill="#0F172A" opacity="0.35"/>
<!-- 页面底色上露出部分看板顶部 -->
<rect x="6" y="6" width="418" height="90" rx="20" fill="url(#hd)" opacity="0.9"/>
<text x="28" y="50" font-size="18" fill="#C7D2FE">2026年9月 · 日历页(被遮罩压暗)</text>
<!-- 抽屉主体:底部滑出 -->
<g transform="translate(0,270)" filter="url(#shadow)">
<rect width="430" height="670" rx="26" fill="#FFFFFF"/>
<!-- 拖拽把手 -->
<rect x="185" y="12" width="60" height="5" rx="2.5" fill="#CBD5E1"/>
<!-- 抽屉标题 -->
<text x="30" y="52" font-size="22" font-weight="800" fill="#0F172A">设置</text>
<text x="334" y="50" font-size="18" fill="#94A3B8">✕ 关闭</text>
<line x1="30" y1="68" x2="400" y2="68" stroke="#F1F5F9" stroke-width="2"/>
<!-- 分区① 薪酬规则 -->
<text x="30" y="104" font-size="15" font-weight="700" fill="#4F46E5">👛 薪酬规则</text>
<rect x="30" y="116" width="370" height="96" rx="12" fill="#F8FAFC" stroke="#EEF2F7"/>
<text x="46" y="144" font-size="14" fill="#475569">基础时薪</text>
<rect x="270" y="128" width="116" height="40" rx="8" fill="#fff" stroke="#E2E8F0"/>
<text x="292" y="155" font-size="15" text-anchor="middle" fill="#0F172A">19.5 元/h</text>
<text x="46" y="184" font-size="14" fill="#475569">满勤天数</text>
<rect x="270" y="168" width="116" height="34" rx="8" fill="#fff" stroke="#E2E8F0"/>
<text x="318" y="191" font-size="15" text-anchor="middle" fill="#0F172A">27 天</text>
<!-- 分区② 奖惩标准 -->
<text x="30" y="244" font-size="15" font-weight="700" fill="#4F46E5">🎖 奖惩标准</text>
<rect x="30" y="256" width="370" height="96" rx="12" fill="#F8FAFC" stroke="#EEF2F7"/>
<text x="46" y="284" font-size="14" fill="#475569">查获奖励 / 件</text>
<rect x="290" y="268" width="96" height="30" rx="8" fill="#ECFDF5"/>
<text x="308" y="289" text-anchor="middle" font-size="14" font-weight="700" fill="#059669">+¥10</text>
<text x="46" y="324" font-size="14" fill="#475569">漏查处罚 / 件</text>
<rect x="290" y="308" width="96" height="30" rx="8" fill="#FFF1F2"/>
<text x="308" y="329" text-anchor="middle" font-size="14" font-weight="700" fill="#E11D48">-¥20</text>
<!-- 分区③ 发放周期 -->
<text x="30" y="386" font-size="15" font-weight="700" fill="#4F46E5">📆 发放周期</text>
<rect x="30" y="398" width="370" height="86" rx="12" fill="#F8FAFC" stroke="#EEF2F7"/>
<text x="46" y="426" font-size="14" fill="#475569">当月发 上半月 → 次月 B 日</text>
<text x="46" y="452" font-size="14" fill="#475569">上半月工资日期 (payDayA)</text>
<rect x="290" y="410" width="96" height="34" rx="8" fill="#fff" stroke="#E2E8F0"/>
<text x="312" y="433" text-anchor="middle" font-size="14" fill="#0F172A">9月22日</text>
<!-- 分区④ 班次默认工时 -->
<text x="30" y="518" font-size="15" font-weight="700" fill="#4F46E5">🕐 班次默认工时</text>
<rect x="30" y="530" width="370" height="86" rx="12" fill="#F8FAFC" stroke="#EEF2F7"/>
<rect x="46" y="544" width="70" height="30" rx="15" fill="#FFEDD5"/>
<text x="58" y="565" font-size="13" font-weight="700" fill="#EA580C">白班 8h</text>
<rect x="126" y="544" width="70" height="30" rx="15" fill="#E0E7FF"/>
<text x="138" y="565" font-size="13" font-weight="700" fill="#4F46E5">夜班 10h</text>
<text x="46" y="600" font-size="12" fill="#94A3B8">夜班跨天,工时全归开始打卡那天</text>
<!-- 分区⑤ 数据 -->
<text x="30" y="650" font-size="15" font-weight="700" fill="#4F46E5">💾 数据</text>
<rect x="30" y="660" width="370" height="88" rx="12" fill="#F8FAFC" stroke="#EEF2F7"/>
<text x="46" y="688" font-size="14" fill="#475569">导出备份(JSON)</text>
<text x="330" y="688" font-size="14" fill="#A0AEC0">↗</text>
<line x1="46" y1="704" x2="384" y2="704" stroke="#EEF2F7"/>
<text x="46" y="726" font-size="14" fill="#475569">恢复默认设置</text>
<text x="330" y="726" font-size="14" fill="#A0AEC0">↗</text>
<line x1="46" y1="718" x2="384" y2="718" stroke="#EEF2F7"/>
<text x="30" y="766" font-size="12" fill="#94A3B8">滑出方式:下滑把手 / 点击遮罩 / 点 ✕ 关闭 · Tab 结构不变,不影响用例20</text>
</g>
</g>
<!-- 底部说明 -->
<rect x="60" y="1350" width="960" height="110" rx="16" fill="#EEF2FF" stroke="#C7D2FE"/>
<text x="90" y="1390" font-size="24" font-weight="700" fill="#312E81">关键决策点</text>
<text x="90" y="1426" font-size="20" fill="#4338CA">抽屉内容 = 现有"设置 Tab"整块搬进底部面板,齿轮/遮罩/下滑三种方式开关,底部 Tab 保留"设置"页签</text>
<text x="90" y="1456" font-size="18" fill="#6366F1">改动范围:新增 settingsOpen 状态 + 抽屉容器;不动 tabs 数组 → 用例20 不受影响</text>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+211
View File
@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 · 底部抽屉面板设计</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; }
body { background: #F1F5F9; padding: 24px; color: #0F172A; }
h1 { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
.sub { font-size: 14px; color: #64748B; margin-bottom: 20px; }
.wrap { display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; }
/* ===== 手机外框 ===== */
.phone {
width: 380px; border-radius: 30px; background: #fff;
box-shadow: 0 20px 50px rgba(49,46,129,.25);
overflow: hidden; border: 1px solid #E2E8F0; position: relative;
}
.phone-title { padding: 12px 20px; font-size: 13px; font-weight: 700; color: #64748B; background:#F8FAFC; border-bottom:1px solid #EEF2F7; }
/* ===== 看板 ===== */
.board {
background: linear-gradient(135deg,#1E293B 0%,#312E81 56%,#4F46E5 100%);
color:#fff; padding: 18px 20px 16px; position: relative;
}
.board .row1 { display:flex; justify-content:space-between; align-items:center; }
.board .month { font-size:16px; font-weight:700; letter-spacing:.5px; }
.gear { width:32px; height:32px; border-radius:50%; background:rgba(255,255,255,.16); display:flex; align-items:center; justify-content:center; font-size:16px; cursor:pointer; }
.board .money { font-size:34px; font-weight:800; margin-top:6px; font-variant-numeric:tabular-nums; }
.board .money small { font-size:15px; opacity:.75; }
.board .payout { font-size:12px; color:#C7D2FE; margin-top:4px; }
.pills { display:flex; gap:6px; margin-top:14px; }
.pill { font-size:11px; padding:4px 10px; border-radius:999px; background:rgba(255,255,255,.14); border:1px solid rgba(255,255,255,.16); }
/* ===== 日历 ===== */
.cal { padding: 12px 14px 16px; }
.cal .week { display:grid; grid-template-columns:repeat(7,1fr); text-align:center; font-size:12px; color:#94A3B8; margin-bottom:6px; }
.cal .week .sun{color:#F87171;}
.cal .week .sat{color:#818CF8;}
.cal .grid { display:grid; grid-template-columns:repeat(7,1fr); gap:4px; }
.cell {
height:58px; border-radius:10px; padding:3px; border:1px solid #EEF2F7;
display:flex; flex-direction:column; justify-content:space-between; position:relative;
}
.cell .top { display:flex; justify-content:space-between; align-items:center; padding:0 3px; }
.cell .day { font-size:13px; font-weight:500; font-variant-numeric:tabular-nums; }
.cell .dot { width:5px; height:5px; border-radius:50%; }
.dot.green{background:#10B981;} .dot.rose{background:#F43F5E;}
.cell .bottom { display:flex; justify-content:center; }
.pill-badge { display:inline-flex; align-items:center; gap:1.5px; padding:1px 4px; border-radius:999px; font-size:10px; font-weight:600; white-space:nowrap; }
.pill-badge.day{background:#FFF7ED;color:#EA580C;}
.pill-badge.night{background:#EEF2FF;color:#4F46E5;}
.pill-badge .t{font-weight:700;}
.st { font-size:11px; }
.st.rest{color:#9CA3AF;} .st.leave{color:#0EA5E9;} .st.empty{color:#D1D5DB;}
.cell-day{background:#FFF7ED;border-color:#FED7AA;}
.cell-night{background:#EEF2FF;border-color:#C7D2FE;}
.cell-rest{background:#F1F5F9;}
.cut { border-bottom:2px dashed #94A3B8; }
/* ===== 底部Tab ===== */
.tabbar { border-top:1px solid #E2E8F0; display:flex; }
.tab { flex:1; padding:8px 0 10px; text-align:center; font-size:10px; color:#94A3B8; position:relative; }
.tab .ic { font-size:19px; display:block; }
.tab.on { color:#4F46E5; }
.tab.on::before { content:''; position:absolute; top:0; left:50%; transform:translateX(-50%); width:34px; height:3px; border-radius:0 0 6px 6px; background:#4F46E5; }
/* ===== 抽屉 ===== */
.drawer-wrap { position:relative; }
.mask { position:absolute; inset:0; border-radius:30px; background:rgba(15,23,42,.45); z-index:2; }
.mask .board { border-radius:30px 30px 0 0; height:96px; opacity:.92; }
.drawer {
position:relative; z-index:3; background:#fff; border-radius:26px 26px 0 0;
padding: 10px 20px 20px; box-shadow:0 -12px 40px rgba(15,23,42,.35);
margin-top:-1px;
}
.handle { width:54px; height:5px; border-radius:3px; background:#CBD5E1; margin:4px auto 12px; }
.d-title { display:flex; justify-content:space-between; align-items:center; font-size:18px; font-weight:800; margin-bottom:12px; }
.d-title .x { color:#94A3B8; font-size:14px; cursor:pointer; }
.group-title { font-size:13px; font-weight:700; color:#4F46E5; margin:14px 0 6px; }
.card { background:#F8FAFC; border:1px solid #EEF2F7; border-radius:12px; padding:10px 12px; font-size:13px; }
.row { display:flex; justify-content:space-between; align-items:center; margin:6px 0; }
.row .l { color:#475569; }
.input { font-size:13px; font-variant-numeric:tabular-nums; color:#0F172A; font-weight:600; background:#fff; border:1px solid #E2E8F0; border-radius:8px; padding:3px 8px; }
.badge-pos { background:#ECFDF5; color:#059669; font-weight:700; padding:2px 8px; border-radius:999px; font-size:12px; }
.badge-neg { background:#FFF1F2; color:#E11D48; font-weight:700; padding:2px 8px; border-radius:999px; font-size:12px; }
.mini-pill { padding:3px 10px; border-radius:999px; font-size:12px; font-weight:700; }
.mini-pill.day{background:#FFEDD5;color:#EA580C;}
.mini-pill.night{background:#E0E7FF;color:#4F46E5;}
.hint { font-size:11px; color:#94A3B8; margin-top:4px; }
.data-item { color:#475569; padding:7px 0; border-bottom:1px solid #EEF2F7; display:flex; justify-content:space-between; }
.data-item:last-child{border-bottom:none;}
.note { font-size:11px; color:#94A3B8; margin-top:12px; line-height:1.5; }
/* ===== 说明卡片 ===== */
.aside { max-width:420px; background:#EEF2FF; border:1px solid #C7D2FE; border-radius:16px; padding:20px 22px; }
.aside h2 { font-size:17px; font-weight:800; color:#312E81; margin-bottom:10px; }
.aside ul { margin-left:18px; color:#4338CA; font-size:14px; line-height:1.7; }
.decision { margin-top:14px; background:#fff; border-radius:10px; padding:12px 14px; color:#1E293B; font-size:13px; line-height:1.6; }
.decision b{color:#4F46E5;}
</style>
</head>
<body>
<h1>设置 · 底部抽屉面板(Bottom Drawer)设计</h1>
<div class="sub">齿轮触发 → 底部滑出 → 五个设置分区;遮罩点击 / 下滑把手 / ✕ 关闭</div>
<div class="wrap">
<!-- 左:滑出前 -->
<div class="phone">
<div class="phone-title">① 滑出前 · 日历页(齿轮在右上角)</div>
<div class="board">
<div class="row1"><span class="month">2026年9月</span><span class="gear">⚙</span></div>
<div class="money"><small>¥</small>6,180.0</div>
<div class="payout">10日 ¥2,940.0 · 22日 ¥3,240.0</div>
<div class="pills">
<span class="pill">出勤 16/27</span>
<span class="pill">工时 124h</span>
<span class="pill">收入 ¥5,000</span>
</div>
</div>
<div class="cal">
<div class="week"><span class="sun">日</span><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span class="sat">六</span></div>
<div class="grid">
<div class="cell"><div class="top"><span class="day">1</span></div><div class="bottom"><span class="st empty">—</span></div></div>
<div class="cell cell-day"><div class="top"><span class="day">2</span><span class="dot green"></span></div><div class="bottom"><span class="pill-badge day"><span class="t">白</span> 8h</span></div></div>
<div class="cell cell-night"><div class="top"><span class="day">3</span><span class="dot rose"></span></div><div class="bottom"><span class="pill-badge night"><span class="t">夜</span> 10h</span></div></div>
<div class="cell cell-rest"><div class="top"><span class="day">4</span></div><div class="bottom"><span class="st rest">休</span></div></div>
<div class="cell"><div class="top"><span class="day">5</span></div><div class="bottom"><span class="st empty">—</span></div></div>
<div class="cell"><div class="top"><span class="day">6</span></div><div class="bottom"><span class="st empty">—</span></div></div>
<div class="cell"><div class="top"><span class="day">7</span></div><div class="bottom"><span class="st empty">—</span></div></div>
<!-- 半月分界行 -->
<div class="cell cut"><div class="top"><span class="day">8</span></div><div class="bottom"><span class="st empty">—</span></div></div>
<div class="cell cut cell-day"><div class="top"><span class="day">9</span></div><div class="bottom"><span class="pill-badge day"><span class="t">白</span> 7.5h</span></div></div>
<div class="cell cut cell-night"><div class="top"><span class="day">10</span></div><div class="bottom"><span class="pill-badge night"><span class="t">夜</span> 6.5h</span></div></div>
</div>
</div>
<div class="tabbar">
<div class="tab"><span class="ic">📅</span>打卡</div>
<div class="tab"><span class="ic">📊</span>汇总</div>
<div class="tab on"><span class="ic">⚙</span>设置</div>
</div>
</div>
<!-- 右:抽屉展开 -->
<div style="display:flex;flex-direction:column;gap:20px;">
<div class="phone" style="height:auto;">
<div class="phone-title">② 抽屉展开 · 遮罩压暗页面 + 底部滑出设置面板</div>
<div class="drawer-wrap">
<div class="mask"><div class="board"><div class="month" style="font-size:13px;opacity:.9;">2026年9月 · 日历页(被遮罩压暗)</div></div></div>
<div class="drawer">
<div class="handle"></div>
<div class="d-title">设置 <span class="x">✕ 关闭</span></div>
<div class="group-title">👛 薪酬规则</div>
<div class="card">
<div class="row"><span class="l">基础时薪</span><span class="input">19.5 元/h</span></div>
<div class="row"><span class="l">满勤天数</span><span class="input">27 天</span></div>
<div class="row"><span class="l">满勤奖金/h</span><span class="input">1 元</span></div>
</div>
<div class="group-title">🎖 奖惩标准</div>
<div class="card">
<div class="row"><span class="l">查获奖励 / 件</span><span class="badge-pos">+¥10</span></div>
<div class="row"><span class="l">漏查处罚 / 件</span><span class="badge-neg">-¥20</span></div>
<div class="row"><span class="l">满勤达标奖励</span><span class="badge-pos">按工时</span></div>
</div>
<div class="group-title">📆 发放周期</div>
<div class="card">
<div class="row"><span class="l">上半月工资日 (payDayA)</span><span class="input">22 号</span></div>
<div class="row"><span class="l">下半月工资日 (payDayB)</span><span class="input">10 号</span></div>
<div class="row"><span class="l">半月分界日 (cutoff)</span><span class="input">15 号</span></div>
</div>
<div class="group-title">🕐 班次默认工时</div>
<div class="card">
<div class="row"><span class="l">白班 / 夜班默认</span>
<span style="display:flex;gap:6px;"><span class="mini-pill day">白 8h</span><span class="mini-pill night">夜 10h</span></span>
</div>
<div class="hint">夜班跨天,工时全归开始打卡那天</div>
</div>
<div class="group-title">💾 数据</div>
<div class="card" style="padding:4px 12px;">
<div class="data-item"><span>导出备份(JSON)</span><span style="color:#A0AEC0;">↗</span></div>
<div class="data-item"><span>复制数据到剪贴板</span><span style="color:#A0AEC0;">↗</span></div>
<div class="data-item"><span>恢复默认设置</span><span style="color:#A0AEC0;">↗</span></div>
</div>
<div class="note">滑出方式:下滑把手 / 点击遮罩 / 点 ✕ 关闭 · 不动 tabs 数组 → 用例20 不受影响</div>
</div>
</div>
</div>
<div class="aside">
<h2>关键决策点</h2>
<ul>
<li>抽屉内容 = 现有「设置 Tab」整块搬进底部面板</li>
<li>三种关闭方式:下滑把手 / 点遮罩 / 点 ✕</li>
<li>齿轮按钮(看板右上)+ 底部 Tab「设置」都触发抽屉</li>
<li><b>不动 tabs 数组</b> → 用例20 不受影响,规避重写测试</li>
</ul>
<div class="decision">
要落地的话改动范围:新增 <b>settingsOpen</b> 状态 + 抽屉容器模板(复用现有设置表单),从中置「齿轮直达设置页」改为「齿轮/遮罩/下滑开关抽屉」。改动集中、可逆。
</div>
</div>
</div>
</div>
</body>
</html>
+99 -237
View File
@@ -1110,6 +1110,14 @@
</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>
@@ -1126,8 +1134,16 @@
// 而原来的做法是立刻把卡片糊在用户脸上,让人手动点一下按钮去做机器该做的事。
// 那张卡除了逼用户点按钮,不提供任何它自己解决不了的信息。
// 现在改成:先静默自动重试,只有连续失败才显示卡片。
if (typeof Vue === 'undefined') {
const VUE_SRC = 'vendor/vue.global.prod.js';
// 启动硬依赖清单。原来只盯 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');
@@ -1149,12 +1165,16 @@ if (typeof Vue === 'undefined') {
const autoLeft = Math.max(0, MAX_AUTO - spent);
let round = 0;
const FAIL_DESC = '渲染引擎 <code class="font-mono-num">vue.global.prod.js</code> 没能就位,界面画不出来。<br />'
// 文案按实际缺的那几项拼,不写死“渲染引擎”——缺的是哪个就说哪个。
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 = '渲染引擎还没到位,正在自动重新获取。<br />你的打卡数据存在本机,一条都不会丢。';
desc.innerHTML = MISS_TEXT + ' 还没到位,正在自动重新获取。<br />你的打卡数据存在本机,一条都不会丢。';
btn.hidden = true;
}
@@ -1167,19 +1187,21 @@ if (typeof Vue === 'undefined') {
detail.textContent = msg;
}
/** 取一次 Vue 并验完整性,成功返回字节数。 */
/** 把缺的依赖逐个取一遍并验完整性,成功返回总字节数。 */
async function refetch() {
// cache:'reload' 强制回源,绕过可能已经缓存下来的坏副本,
// 成功后好的副本会写回缓存,紧接着的 reload 才拿得到完整文件。
const r = await fetch(VUE_SRC, { cache: 'reload' });
if (!r.ok) throw new Error('服务器返回 HTTP ' + r.status);
const text = await r.text();
// 不查字节数(会跟着 Vue 版本变),直接查页面真正要用的入口是否在,
// 内容被截断时这一项必然查不到。
if (!text.includes('createApp')) {
throw new Error('取回的内容不完整(' + text.length + ' 字节,缺 createApp)');
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 text.length;
return total;
}
async function tryLoad(auto) {
@@ -1222,38 +1244,27 @@ if (typeof Vue === 'undefined') {
card.hidden = false;
}
throw new Error('Vue 不可用,已切到 boot-fail 流程');
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() {
// ===== 默认规则 =====
const DEFAULTS = {
rate: 19.5, // 基础时薪,当月工资只含工时
fullDays: 27, // 满勤门槛(天)
fullBonusPerHour: 1, // 满勤奖 = 当月总工时 × 该单价(属奖励,次月结)
foundBonus: 10, // 查获违禁品 元/件
missedPenalty: 20, // 漏查违禁品 元/件
cutoff: 15, // 上半月截止日
payDayA: 22, // 上半月工时 → 当月该日发
payDayB: 10, // 下半月工时 + 当月奖惩 → 次月该日发
dayHours: 8, // 白班默认工时
nightHours: 10, // 夜班默认工时
defaultShift: 'day' // 打卡默认班次:'day' 白班 / 'night' 夜班
};
// ===== 默认规则与字段标签 =====
// 口径常量只存 shared/payroll.js 一份。之前两处各留一份,改时薪要改两个文件,
// 忘一个就是页面与测试对不上 —— 那种不一致最难查,因为两边各自都「自洽」。
const DEFAULTS = P.DEFAULTS;
// 设置字段的中文名:登录合并冲突对照时要给人看,不能直接抱 key
const SETTING_LABELS = P.SETTING_LABELS;
const settings = reactive({ ...DEFAULTS });
const records = ref({});
// 设置字段的中文名:登录合并冲突对照时要给人看,不能直接抱 key
const SETTING_LABELS = {
rate: '基础时薪', fullDays: '满勤门槛(天)', fullBonusPerHour: '满勤奖单价',
foundBonus: '查获奖励/件', missedPenalty: '漏查处罚/件', cutoff: '上半月截止日',
payDayA: '发薪日 A', payDayB: '发薪日 B', dayHours: '白班默认工时',
nightHours: '夜班默认工时', defaultShift: '默认班次'
};
// 月度奖惩终稿:key = 'YYYY-MM'
// { status:'draft'|'final', finalAmount:Number|null, note:String, finalizedAt:ISOString }
// 次月 1–2 号汇总申报后,实际审核金额可能与日常累计不同(如累计 100、审核只认 80)。
@@ -1261,46 +1272,17 @@ createApp({
const monthAdjust = ref({});
// ===== 工时口径:6–12h,0.5 步进 =====
const HOUR_MIN = 6, HOUR_MAX = 12, HOUR_STEP = 0.5;
const hourOptions = [];
for (let h = HOUR_MIN; h <= HOUR_MAX + 1e-9; h += HOUR_STEP) hourOptions.push(Math.round(h * 10) / 10);
// 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;
// 整数部与小数部拆开渲染(仅用于日历格子):小数位占固定宽度槽位,
// 这样 8 与 8.5 的整数部左右对齐,不会形成错位。
const hInt = (v) => Math.floor(Number(v) || 0);
const hFrac = (v) => (Number(v) || 0) % 1 === 0.5 ? '.5' : '';
// 仅用于归一「设置里的班次默认工时」,保证默认值落在可选范围内。
// 不对已保存的打卡记录做任何改写。
const clampHours = (v, fallback) => {
const n = Number(v);
if (!Number.isFinite(n)) return fallback === undefined ? DEFAULTS.dayHours : fallback;
const snapped = Math.round(n / HOUR_STEP) * HOUR_STEP;
return Math.min(HOUR_MAX, Math.max(HOUR_MIN, snapped));
};
// ===== 数值兜底:输入框清空 / 脏数据时回落默认值,不让计算变 0 或 NaN =====
// 注意 Number(null) 与 Number('') 都等于 0,必须先判空,否则清空输入框会把时薪静默变成 0。
const sn = (v, d) => {
if (v === null || v === undefined || v === '') return d;
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? n : d;
};
const cfg = computed(() => ({
rate: sn(settings.rate, DEFAULTS.rate),
fullDays: Math.round(sn(settings.fullDays, DEFAULTS.fullDays)) || DEFAULTS.fullDays,
fullBonusPerHour: sn(settings.fullBonusPerHour, DEFAULTS.fullBonusPerHour),
foundBonus: sn(settings.foundBonus, DEFAULTS.foundBonus),
missedPenalty: sn(settings.missedPenalty, DEFAULTS.missedPenalty),
cutoff: Math.min(28, Math.max(1, Math.round(sn(settings.cutoff, DEFAULTS.cutoff)))),
// payDayA/payDayB 原本只用于文案展示,现在参与日历发薪日判定,必须进 cfg 做同样的洗值,
// 否则 payDayKind() 拿到 undefined,比较永远不命中,角标与内描边都不会出现。
payDayA: Math.min(31, Math.max(1, Math.round(sn(settings.payDayA, DEFAULTS.payDayA)))),
payDayB: Math.min(31, Math.max(1, Math.round(sn(settings.payDayB, DEFAULTS.payDayB)))),
dayHours: clampHours(sn(settings.dayHours, DEFAULTS.dayHours), DEFAULTS.dayHours),
nightHours: clampHours(sn(settings.nightHours, DEFAULTS.nightHours), DEFAULTS.nightHours),
defaultShift: settings.defaultShift === 'night' ? 'night' : 'day'
}));
// 洗过的有效配置:所有算钱逻辑只认这个值,不直接读原始 settings。
// 包成 computed 是为了让 settings 每次改动都重洗一遍并触发下游重算。
// payDayA/payDayB 也必须一起洗 —— 它们现在参与日历发薪日判定,
// 拿到 undefined 时 payDayKind() 比较永不命中,角标与内描边会整个消失。
const cfg = computed(() => P.normalizeSettings(settings));
// ===== 页签 =====
const tab = ref('calendar');
@@ -1333,130 +1315,49 @@ createApp({
setTimeout(() => { if (toast.value === msg) toast.value = ''; }, 2000);
};
function newDraft(shift) {
return {
status: 'work',
shift_type: shift,
hours: shift === 'night' ? cfg.value.nightHours : cfg.value.dayHours,
contraband_found: 0,
contraband_missed: 0,
other_penalty: null,
penalty_reason: ''
};
}
const newDraft = (shift) => P.newDraft(shift, cfg.value);
const draftRecord = ref(newDraft(cfg.value.defaultShift));
// ===== 日期辅助 =====
const daysInMonth = computed(() => new Date(currentYear.value, currentMonth.value + 1, 0).getDate());
const firstDayOfWeek = computed(() => new Date(currentYear.value, currentMonth.value, 1).getDay());
const monthPrefix = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}-`;
const keyOf = (y, m, d) => monthPrefix(y, m) + String(d).padStart(2, '0');
const monthKeyOf = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}`;
const getMonthAdjust = (y, m) => monthAdjust.value[monthKeyOf(y, m)] || null;
const { 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)];
const prevMonthOf = (y, m) => m === 0 ? { year: y - 1, month: 11 } : { year: y, month: m - 1 };
const nextMonthOf = (y, m) => m === 11 ? { year: y + 1, month: 0 } : { year: y, month: m + 1 };
const isToday = (day) => {
const n = new Date();
return n.getFullYear() === currentYear.value && n.getMonth() === currentMonth.value && n.getDate() === day;
};
const isCurrentMonth = computed(() => {
const n = new Date();
return n.getFullYear() === currentYear.value && n.getMonth() === currentMonth.value;
});
// 这两个要读「今天」。模块里不允许自己取 now(否则测试基准每天都不一样),
// 所以由调用方决定:页面上就是省略实参,让模块回落到 new Date()。
const isToday = (day) => P.isSameDay(currentYear.value, currentMonth.value, day);
const isCurrentMonth = computed(() => P.isCurrentMonth(currentYear.value, currentMonth.value));
const fmt = (n) => (Number(n) || 0).toFixed(1);
const money = (n) => '¥' + (Number(n) || 0).toFixed(1);
const signed = (n) => { const v = Number(n) || 0; return (v >= 0 ? '+¥' : '-¥') + Math.abs(v).toFixed(1); };
// 金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
// 必须从 toFixed(1) 的结果上切,不能用 Math.floor 另算,
// moneyParts 把金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
// 它必须从 toFixed(1) 的结果上切,不能用 Math.floor 另算,
// 否则 99.96 会被 money() 进为 100.0、而拆分出 99.0,两处数字对不上。
const moneyParts = (n) => {
const v = Number(n) || 0;
const s = Math.abs(v).toFixed(1);
const dot = s.indexOf('.');
return {
sign: v < 0 ? '-' : '',
int: s.slice(0, dot).replace(/\B(?=(\d{3})+(?!\d))/g, ','), // 千分位
frac: s.slice(dot)
};
};
const { fmt, money, signed, moneyParts } = P;
// ===== 核心聚合:本月 / 上月共用同一套口径 =====
// 夜班跨天:工时全部归入开始打卡的那一天。
const aggregateMonth = (year, month) => {
const c = cfg.value;
const prefix = monthPrefix(year, month);
let h1 = 0, h2 = 0, workDays = 0, dayShifts = 0, nightShifts = 0, restDays = 0, leaveDays = 0;
let foundCount = 0, missedCount = 0, otherPenalties = 0;
for (const key in records.value) {
if (key.indexOf(prefix) !== 0) continue;
const rec = records.value[key];
if (!rec) continue;
if (rec.status === 'rest') { restDays++; continue; }
if (rec.status === 'leave') { leaveDays++; continue; }
if (rec.status !== 'work') continue;
const day = Number(key.slice(prefix.length));
const hours = Number(rec.hours || 0);
if (day <= c.cutoff) h1 += hours; else h2 += hours;
workDays++;
if (rec.shift_type === 'night') nightShifts++; else dayShifts++;
foundCount += Number(rec.contraband_found || 0);
missedCount += Number(rec.contraband_missed || 0);
otherPenalties += Math.max(0, Number(rec.other_penalty || 0));
}
const totalHours = h1 + h2;
// 满勤奖只由出勤天数客观判定,不参与人工审核修正
const fullBonus = workDays >= c.fullDays ? totalHours * c.fullBonusPerHour : 0;
const foundBonus = foundCount * c.foundBonus;
const missedPenalty = missedCount * c.missedPenalty;
// 日常累计奖惩 = 查获 - 漏查 - 其他扣款(不含满勤奖)。
// 这只是「记录」,不是可发金额:查获多少件当月只记账,实际奖金要等次月初组长
// 审核提交、扣掉漏查与其他扣款后才确定。
const rawReview = foundBonus - missedPenalty - otherPenalties;
// 与「满勤未达标 → 满勤奖按 0 计」同一个道理:
// 奖惩未定稿 → 一律按 0 计,不进任何应得/发放口径;只有定稿后才用审核终稿金额。
const adj = getMonthAdjust(year, month);
const finalized = !!(adj && adj.status === 'final' && Number.isFinite(Number(adj.finalAmount)));
const reviewAmount = finalized ? Number(adj.finalAmount) : 0;
const reviewDelta = finalized ? reviewAmount - rawReview : 0;
// 计入应得的奖惩净额:未定稿恒为 0,页面单列这一项并标明「未定稿不计」。
const reviewNet = reviewAmount;
// 发放净额 = 满勤奖 + 已定稿奖惩。次月 payDayB 这两笔与下半月工时一起发,
// 「挣得」与「结转」两套口径都复用它,保证同名同数。
const adjustNet = fullBonus + reviewNet;
return {
h1, h2, totalHours, workDays, dayShifts, nightShifts, restDays, leaveDays,
foundCount, missedCount, otherPenalties,
fullBonus, foundBonus, missedPenalty, adjustNet, reviewNet,
rawReview, reviewAmount, reviewDelta, finalized,
adjustStatus: adj ? adj.status : null,
adjustNote: adj && adj.note ? adj.note : '',
finalizedAt: adj && adj.finalizedAt ? adj.finalizedAt : '',
basePay: totalHours * c.rate
};
};
// 口径本体在 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));
// 发放口径:
// 当月 payDayA = 当月 1–cutoff 工时 × rate
// 当月 payDayB = 上月 cutoff+1–月底 工时 × rate + 上月奖惩净额
const payoutThisA = computed(() => Math.max(0, monthStats.value.h1 * cfg.value.rate));
const payoutThisB = computed(() => Math.max(0, prevStats.value.h2 * cfg.value.rate + prevStats.value.adjustNet));
const payoutNextB = computed(() => Math.max(0, monthStats.value.h2 * cfg.value.rate + monthStats.value.adjustNet));
// 发放口径与两套记账口径一次算齐(细节见 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–月底干出来的钱,不管什么时候发。打一天卡当天就涨。
@@ -1467,46 +1368,21 @@ createApp({
// = 工时收入 + 满勤奖(未达 27 天为 0)+ 奖惩净额(未定稿取日常累计,已定稿取终稿)
// adjustNet 本身 = fullBonus + reviewAmount,正好是「满勤奖 + 奖惩净额」。
// 打卡页大字与汇总页「合计应得」共用这一个值,不再有两个不同的「挣得」。
const earnedTotal = computed(() => monthStats.value.basePay + monthStats.value.adjustNet);
const cashTotal = computed(() => payoutThisB.value + payoutThisA.value);
const earnedTotal = computed(() => flows.value.earnedTotal);
const cashTotal = computed(() => flows.value.cashTotal);
// 「挣得」拆成两笔落地时间:上半月工时随本月 payDayA;下半月工时 + 奖惩结转次月 payDayB。
// 这一行是两套口径之间唯一的桥,解释了为何挣得 ≠ 进账。
// 注意:结转那笔只用「已确认的奖惩」,满勤奖月底才确定,不计入挣得(另作预估展示)。
// 决策2:结转 = 次月 payDayB 实际到账那一笔,必须含满勤奖(业务规则:满勤奖压到次月 10 号发)。
// 直接复用 payoutNextB,保证「结转」与「次月实际上账」永远同一个数,不再出现账目缺口。
const earnedCarry = computed(() => payoutNextB.value);
const earnedSplit = computed(() => ({ paid: payoutThisA.value, carry: earnedCarry.value }));
const earnedCarry = computed(() => flows.value.carry);
const earnedSplit = computed(() => flows.value.split);
// 上半月那笔到没到账,只在看当前月份时才有「待发」说法;翻历史月份一律算已发。
const payDayAPassed = computed(() => {
if (!isCurrentMonth.value) {
const n = new Date();
return currentYear.value < n.getFullYear() ||
(currentYear.value === n.getFullYear() && currentMonth.value < n.getMonth());
}
return new Date().getDate() >= cfg.value.payDayA;
});
const payDayAPassed = computed(() => P.payDayAPassed(currentYear.value, currentMonth.value, cfg.value));
// 处罚吃穿下半月工时时,结转那笔被 ¥0 兰住,于是 paid+carry > earnedTotal。页面需要明说。
const earnedFloored = computed(() => monthStats.value.h2 * cfg.value.rate + monthStats.value.adjustNet < 0);
const earnedFloored = computed(() => flows.value.floored);
const penaltyDetails = computed(() => {
const c = cfg.value;
const prefix = monthPrefix(currentYear.value, currentMonth.value);
const out = [];
for (const key in records.value) {
if (key.indexOf(prefix) !== 0) continue;
const r = records.value[key];
if (!r || r.status !== 'work') continue;
const missed = Number(r.contraband_missed || 0);
const other = Math.max(0, Number(r.other_penalty || 0));
const amount = missed * c.missedPenalty + other;
if (amount <= 0) continue;
const parts = [];
if (missed > 0) parts.push(`漏查${missed}件`);
if (other > 0) parts.push(r.penalty_reason ? r.penalty_reason : '其他扣款');
out.push({ day: Number(key.slice(prefix.length)), reason: parts.join('·'), amount });
}
return out.sort((a, b) => a.day - b.day);
});
const penaltyDetails = computed(() => P.penaltyDetails(currentYear.value, currentMonth.value, payrollCtx()));
const recordCount = computed(() => Object.keys(records.value).length);
@@ -1514,7 +1390,7 @@ createApp({
// 明细行三项(工时收入 + 满勤奖 + 奖惩净额)相加必须严丝合缝等于这个大字。
const boardMain = computed(() => moneyParts(earnedTotal.value));
// 距满勤还差几天(达标后为 0),供顶部进度胶囊使用
const fullDaysLeft = computed(() => Math.max(0, cfg.value.fullDays - monthStats.value.workDays));
const fullDaysLeft = computed(() => P.fullDaysLeft(monthStats.value, cfg.value));
const hasPenalty = (day) => {
const r = getRecord(day);
@@ -1523,12 +1399,7 @@ createApp({
// 发薪日判定:'a' = 当月 payDayA(上半月工时,线上);'b' = 当月 payDayB(上月下半月工时 + 上月奖惩,线下)
// 只做统计标注,不处理"遇周末顺延",实际到账日以单位为准。
const payDayKind = (day) => {
const c = cfg.value;
if (day === c.payDayA) return 'a';
if (day === c.payDayB) return 'b';
return null;
};
const payDayKind = (day) => P.payDayKind(day, cfg.value);
// 选中日如果是发薪日,打卡抽屉顶部展示该笔到账(不打断打卡主流程)
const selectedPayInfo = computed(() => {
@@ -1553,10 +1424,7 @@ createApp({
// 上下半月分界格:7 列网格里 day+7 就在 day 正下方,
// 所以给 cutoff-6 … cutoff 这 7 格加底虚线,正好描出一条完整分界线,
// 而且不需要往网格里插额外元素,星期对齐不会被打买。
const isCutEdge = (day) => {
const c = cfg.value.cutoff;
return day <= c && day + 7 > c;
};
const isCutEdge = (day) => P.isCutEdge(day, cfg.value);
const cellClass = (day) => {
const r = getRecord(day);
@@ -1688,14 +1556,8 @@ createApp({
};
const saveRecord = () => {
const op = Number(draftRecord.value.other_penalty);
draftRecord.value.other_penalty = (Number.isFinite(op) && op > 0) ? op : null;
if (draftRecord.value.status !== 'work') {
draftRecord.value.contraband_found = 0;
draftRecord.value.contraband_missed = 0;
draftRecord.value.other_penalty = null;
draftRecord.value.penalty_reason = '';
}
// 原地归一:非正扣款回落 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);
+17
View File
@@ -0,0 +1,17 @@
{
"name": "work-hours-tracker",
"version": "1.0.0",
"private": true,
"description": "工时记账:单文件前端 + 薪酬纯模块(shared/payroll.js)+ 平台无关服务端(server/)",
"scripts": {
"test": "npm run test:payroll && npm run test:server",
"test:payroll": "node --test-reporter=spec --test test/payroll.test.js test/app-parity.test.js test/baseline.test.js",
"test:server": "npm --prefix server test",
"baseline": "node tools/gen-baseline.js",
"baseline:write": "node tools/gen-baseline.js --write",
"start": "npm --prefix server start"
},
"engines": {
"node": ">=22.5.0"
}
}
+64
View File
@@ -0,0 +1,64 @@
// 无头实测缓存策略:全新浏览器首访 → 二次刷新,分别记录实际发出的网络请求。
// 关注三件事:
// 1. 全新浏览器(无 localStorage)能否正常起 Vue,页面里有几条记录
// 2. 第二次刷新时 vendor/ 是否真的不再走网络
// 3. index.html 是否命中 304
const puppeteer = require('/root/.openclaw/workspace/web_tasks/node_modules/puppeteer');
const fs = require('fs');
const URL = 'http://127.0.0.1:9522/';
const PROFILE = '/tmp/wht-cache-test-profile';
(async () => {
fs.rmSync(PROFILE, { recursive: true, force: true });
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium',
headless: 'new',
userDataDir: PROFILE,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--window-size=390,844']
});
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844, isMobile: true, deviceScaleFactor: 2 });
const log = [];
page.on('response', r => {
const u = r.url().replace(URL, '/');
log.push({ u, status: r.status(), fromCache: r.fromCache(), cc: r.headers()['cache-control'] || '-' });
});
const dump = tag => {
console.log(`\n===== ${tag} =====`);
for (const r of log) {
console.log(
`${String(r.status).padEnd(4)} ${r.fromCache ? 'CACHE ' : 'NET '} ${r.u.slice(0, 60).padEnd(62)} ${r.cc}`
);
}
log.length = 0;
};
// ---- 第一次:全新浏览器,零 localStorage ----
await page.goto(URL, { waitUntil: 'networkidle2', timeout: 30000 });
dump('首访(全新浏览器)');
const s1 = await page.evaluate(() => ({
vue: typeof Vue !== 'undefined',
bootFailVisible: !document.getElementById('boot-fail')?.hidden,
appExists: !!document.getElementById('app'),
// 页面真的渲染出内容了才算挂载成功
mounted: !!document.querySelector('#app')?.innerText?.trim().length,
localRecords: Object.keys(JSON.parse(localStorage.getItem('work_records') || '{}')).length,
hasToken: !!localStorage.getItem('work_sync_token'),
headline: document.querySelector('#app')?.innerText?.split('\n').filter(Boolean).slice(0, 6)
}));
console.log('\n首访页面状态:', JSON.stringify(s1, null, 2));
await page.screenshot({ path: '/root/.openclaw/workspace/work-hours-tracker/png/newbrowser_first.png' });
// ---- 第二次:普通刷新(不清缓存),看还剩多少字节要走网络 ----
await page.reload({ waitUntil: 'networkidle2', timeout: 30000 });
dump('二次刷新(同一浏览器)');
const s2 = await page.evaluate(() => typeof Vue !== 'undefined');
console.log('\n二次刷新 Vue 可用:', s2);
await browser.close();
})().catch(e => { console.error('FAIL', e); process.exit(1); });
+98
View File
@@ -0,0 +1,98 @@
// 无头实测:点齿轮 -> 设置抽屉是否弹出(returnByValue 版)
const { spawn } = require('child_process');
const fs = require('fs');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:9522/index.html';
const chrome = spawn(CHROME, [
'--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9225', `--user-data-dir=${OUT_DIR}/.chrome-test`,
'--window-size=420,900','about:blank'
], { stdio: ['ignore','ignore','pipe'] });
const stderr = [];
chrome.stderr.on('data', d => stderr.push(d.toString()));
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWsUrl() {
for (let i = 0; i < 40; i++) {
try {
const tabs = await (await fetch('http://127.0.0.1:9225/json/list')).json();
if (tabs.length) return tabs[0].webSocketDebuggerUrl;
} catch (e) {}
await sleep(250);
}
throw new Error('CDP 连接失败: ' + stderr.join('').slice(-400));
}
(async () => {
try {
const wsUrl = await getWsUrl();
const ws = new WebSocket(wsUrl);
let id = 0; const pending = new Map(); const logs = [];
let openRes; const openP = new Promise(r => openRes = r);
ws.onopen = openRes;
ws.onmessage = ev => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
else if (m.method === 'Runtime.consoleAPICalled') logs.push('C ' + (m.params.args||[]).map(a=>a.value??a.description??'').join(' '));
else if (m.method === 'Runtime.exceptionThrown') logs.push('EXC ' + JSON.stringify(m.params.exceptionDetails));
};
await openP;
const send = (method, params={}) => new Promise(res => { const mid=++id; pending.set(mid,res); ws.send(JSON.stringify({id:mid,method,params})); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
if (r.result && r.result.exceptionDetails) return { __err: JSON.stringify(r.result.exceptionDetails) };
return r.result && r.result.result && r.result.result.value;
};
await send('Runtime.enable'); await send('Page.enable');
await send('Page.navigate', { url: URL }); await sleep(2200);
// 1) 初始状态有多少 .z-50
const before = await ev(`({ z50: document.querySelectorAll('.z-50').length, gear: !!document.querySelector('button[title="设置"]') })`);
console.log('初始:', JSON.stringify(before));
// 2) 点齿轮
const after = await ev(`
new Promise(r => {
const btn = document.querySelector('button[title="设置"]');
if (!btn) return r({ err: 'no gear btn' });
btn.click();
setTimeout(() => {
const all = document.querySelectorAll('.fixed.inset-0');
const viz = [];
all.forEach(d => {
const s = getComputedStyle(d);
viz.push({ cls: d.className.replace(/\\s+/g,' ').slice(0,70), disp: s.display, vis: s.visibility, op: s.opacity });
});
r({
z50: document.querySelectorAll('.z-50').length,
overlays: viz,
toolbarCooked: document.body.innerText.includes('薪酬规则'),
gearIcon: btn.outerHTML.slice(0,120)
});
}, 450);
})
`);
console.log('点齿轮后:', JSON.stringify(after, null, 2));
// 3) 强制直接验证 settingsOpen 是否被置 true(绕过 DOM)
const stateProbe = await ev(`(function(){
// 尝试从 app 实例读状态不可行(生产vue未暴露),改为再点一次+立即读 body
return 'probe';
})()`);
const shot = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(OUT_DIR + '/gear_final.png', Buffer.from(shot.result.data, 'base64'));
console.log('截图 gear_final.png');
console.log('\n=== 控制台 ===');
console.log(logs.length ? logs.join('\n') : '(无)');
ws.close(); chrome.kill(); process.exit(0);
} catch (e) {
console.error('FAILED:', e.message); console.error(stderr.join('').slice(-400));
try { chrome.kill(); } catch(_){} process.exit(1);
}
})();
+64
View File
@@ -0,0 +1,64 @@
// 专门验刷新循环的防线。
//
// 模拟最刁的一种故障:fetch 能拿到完整文件(所以重试判定为"成功"),
// 但 <script src> 始终起不来(插件拦截、CSP、解析失败都可能这样)。
// 这时每次 reload 都会重新触发一次"成功→reload",若没有计数器就是无限刷新,
// 用户连导出备份都点不到。
//
// 用 resourceType 区分:script 全部 abort,fetch 全部放行。
const puppeteer = require('/root/.openclaw/workspace/web_tasks/node_modules/puppeteer');
const fs = require('fs');
const URL = 'http://127.0.0.1:9522/';
const VUE = 'vue.global.prod.js';
const PROFILE = '/tmp/wht-loop-guard';
(async () => {
fs.rmSync(PROFILE, { recursive: true, force: true });
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium',
headless: 'new',
userDataDir: PROFILE,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844, isMobile: true, deviceScaleFactor: 2 });
let scriptBlocked = 0, fetchAllowed = 0, docLoads = 0;
await page.setRequestInterception(true);
page.on('request', r => {
const isVue = r.url().includes(VUE);
if (isVue && r.resourceType() === 'script') { scriptBlocked++; return r.abort('failed'); }
if (isVue) fetchAllowed++;
if (r.resourceType() === 'document') docLoads++;
r.continue();
});
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await new Promise(r => setTimeout(r, 12000)); // 留足时间,若会循环这里必然刷很多次
const s = await page.evaluate(() => ({
cardShown: !document.getElementById('boot-fail')?.hidden,
title: document.getElementById('boot-title')?.textContent,
detail: document.getElementById('boot-detail')?.textContent,
btnUsable: document.getElementById('boot-retry')?.hidden === false,
retryCount: sessionStorage.getItem('wht_boot_retry')
}));
// 文档加载次数 = 1 次首访 + 最多 MAX_AUTO(2) 次 reload
const capped = docLoads <= 3;
const settled = s.cardShown && s.btnUsable;
const pass = capped && settled;
console.log(`[${pass ? 'PASS' : 'FAIL'}] 刷新循环防线`);
console.log(` 文档加载 ${docLoads} 次(上限应为 3 = 首访 + 2 次自动 reload)`);
console.log(` script 被拦 ${scriptBlocked} 次 / fetch 放行 ${fetchAllowed} 次`);
console.log(` 重试计数=${s.retryCount} 卡片显示=${s.cardShown} 按钮可点=${s.btnUsable}`);
console.log(` 标题="${s.title}"`);
console.log(` 详情="${s.detail}"`);
await page.screenshot({ path: '/root/.openclaw/workspace/work-hours-tracker/png/retry_loopguard.png' });
await browser.close();
fs.rmSync(PROFILE, { recursive: true, force: true });
process.exit(pass ? 0 : 1);
})().catch(e => { console.error('FAIL', e); process.exit(1); });
+60
View File
@@ -0,0 +1,60 @@
// 深挖:为什么 nav button = 0 —— 检查底部 nav 真实 DOM
const { spawn } = require('child_process');
const fs = require('fs');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:8388/index.html';
const chrome = spawn(CHROME, [
'--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9227', `--user-data-dir=${OUT_DIR}/.chrome-test3`,
'--window-size=420,900','about:blank'
], { stdio: ['ignore','ignore','pipe'] });
const stderr = [];
chrome.stderr.on('data', d => stderr.push(d.toString()));
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWsUrl() {
for (let i=0;i<40;i++){ try{ const t=await(await fetch('http://127.0.0.1:9227/json/list')).json(); if(t.length)return t[0].webSocketDebuggerUrl; }catch(e){} await sleep(250);} throw new Error('CDP fail: '+stderr.join('').slice(-400));
}
(async () => {
try {
const wsUrl = await getWsUrl();
const ws = new WebSocket(wsUrl);
let id=0; const pending=new Map(); const logs=[];
let openRes; const openP=new Promise(r=>openRes=r);
ws.onopen=openRes;
ws.onmessage=ev=>{ const m=JSON.parse(ev.data);
if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}
else if(m.method==='Runtime.consoleAPICalled')logs.push('C '+(m.params.args||[]).map(a=>a.value??a.description??'').join(' '));
else if(m.method==='Runtime.exceptionThrown')logs.push('EXC '+JSON.stringify(m.params.exceptionDetails)); };
await openP;
const send=(method,params={})=>new Promise(res=>{const mid=++id;pending.set(mid,res);ws.send(JSON.stringify({id:mid,method,params}));});
const ev=async(expr)=>{const r=await send('Runtime.evaluate',{expression:expr,awaitPromise:true,returnByValue:true}); if(r.result&&r.result.exceptionDetails)return{__err:JSON.stringify(r.result.exceptionDetails)}; return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
await send('Page.navigate',{url:URL}); await sleep(2200);
const probe = await ev(`
(function(){
const navs = document.querySelectorAll('nav');
const allButtons = document.querySelectorAll('button');
const withIco = document.querySelectorAll('svg.ico').length;
// 找包含 settings 图标的按钮
const gear = document.querySelector('button[title="设置"]');
const gearInfo = gear ? { found:true, parent: gear.parentElement.tagName+'.'+(gear.parentElement.className||'').slice(0,40), parentPar: gear.parentElement.parentElement.tagName+'.'+(gear.parentElement.parentElement.className||'').slice(0,40) } : { found:false };
return {
navCount: navs.length,
navOuterHTMLlen: navs.length ? navs[0].outerHTML.length : 0,
navFirst80: navs.length ? navs[0].outerHTML.slice(0,80) : null,
allButtonCount: allButtons.length,
buttonsText: Array.from(allButtons).map(b=>'['+((b.innerText||'').trim()||'~')+']').join(','),
svgIconCount: withIco,
gearInfo
};
})()
`);
console.log('DOM 深挖:', JSON.stringify(probe, null, 2));
ws.close(); chrome.kill(); process.exit(0);
} catch(e){ console.error('FAILED:',e.message); console.error(stderr.join('').slice(-400)); try{chrome.kill();}catch(_){} process.exit(1); }
})();
+66
View File
@@ -0,0 +1,66 @@
// 一次性核查:重构后页面上的关键交互元素还在不在。
// 起因:png/gear_test.js 与 png/tab_settings_test.js 报「no gear btn」「navBtnCount:0」,
// 需要分清是重构改坏了 DOM,还是那两个脚本本身找错了选择器。
const puppeteer = require('/root/.openclaw/workspace/web_tasks/node_modules/puppeteer');
(async () => {
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium',
headless: 'new',
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844, isMobile: true, deviceScaleFactor: 2 });
const errs = [];
page.on('pageerror', e => errs.push('pageerror: ' + e.message));
page.on('console', m => { if (m.type() === 'error') errs.push('console: ' + m.text()); });
await page.goto('http://127.0.0.1:9522/', { waitUntil: 'networkidle2', timeout: 30000 });
await new Promise(r => setTimeout(r, 1200));
const probe = await page.evaluate(() => {
const q = (s) => document.querySelector(s);
const all = (s) => Array.from(document.querySelectorAll(s));
const navBtns = all('nav button, footer button, .fixed.bottom-0 button');
return {
mounted: !!q('#app') && q('#app').innerText.trim().length > 0,
hasWHTPayroll: typeof window.WHTPayroll !== 'undefined',
payrollKeys: typeof window.WHTPayroll !== 'undefined' ? Object.keys(window.WHTPayroll).length : 0,
gearByTitle: !!q('button[title="设置"]'),
navBtnCount: navBtns.length,
navLabels: navBtns.map(b => (b.innerText || '').trim()).filter(Boolean),
// 日历格子数与首格偏移:验证 daysInMonth / firstDayOfWeek 接线正确
dayCells: all('#app [class*="aspect"]').length,
// 大字看板:验证 boardMain / moneyParts 接线
board: (q('#app') ? q('#app').innerText : '').split('\n').slice(0, 8),
cutEdge: all('.cut-edge').length,
payRingA: all('.pay-ring-a').length,
payRingB: all('.pay-ring-b').length
};
});
console.log(JSON.stringify(probe, null, 2));
console.log('\n控制台错误:', errs.length ? errs : '(无)');
// 点一下汇总 Tab,再点设置 Tab,看是否真的切得动
const clicked = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const find = (t) => btns.find(b => (b.innerText || '').trim() === t);
const out = {};
for (const t of ['汇总', '设置', '打卡']) out[t] = !!find(t);
const s = find('设置');
if (s) s.click();
return out;
});
await new Promise(r => setTimeout(r, 600));
const afterSettings = await page.evaluate(() => {
const app = document.querySelector('#app');
const txt = app ? app.innerText : '';
return { showsRateField: txt.includes('基础时薪'), showsFullDays: txt.includes('满勤') };
});
console.log('\nTab 按钮存在性:', clicked);
console.log('点设置后:', afterSettings);
await page.screenshot({ path: '/root/.openclaw/workspace/work-hours-tracker/png/postrefactor_settings.png' });
await browser.close();
})().catch(e => { console.error('FAIL', e.message); process.exit(1); });
+41
View File
@@ -0,0 +1,41 @@
// 量胶囊实际宽度:模拟 360px(小屏,DPR3 → 1080px 截图)与 390/430
const { spawn } = require('child_process');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:8388/index.html';
const chrome = spawn(CHROME, ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9236', `--user-data-dir=${OUT_DIR}/.chrome-pill`,'--window-size=360,900','about:blank'
], { stdio:['ignore','ignore','pipe'] });
const stderr=[]; chrome.stderr.on('data',d=>stderr.push(d.toString()));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9236/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('CDP fail '+stderr.join('').slice(-300));}
(async()=>{
const ws=new WebSocket(await getWs()); let id=0; const pending=new Map();
let or; const op=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data); if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}};
await op;
const send=(me,p={})=>new Promise(r=>{const i=++id;pending.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:p}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
for (const W of [360, 390, 414, 430]) {
await send('Emulation.setDeviceMetricsOverride',{width:W,height:900,deviceScaleFactor:1,mobile:true});
await send('Page.navigate',{url:URL}); await sleep(1300);
// 灌数据造出 ¥282.8 这种较长的数字
await ev(`(function(){const rec={};for(let d=1;d<=2;d++)rec['2026-09-0'+d]={status:'work',shift_type:'day',hours:d===1?8:6.5,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};localStorage.setItem('work_records',JSON.stringify(rec));return 1;})()`);
await send('Page.navigate',{url:URL}); await sleep(1300);
const r = await ev(`(function(){
const wrap = document.querySelector('header .flex.flex-wrap');
const ps = Array.from(document.querySelectorAll('header .pill'));
const cs = getComputedStyle(wrap);
const gap = parseFloat(cs.gap)||6;
const items = ps.map(p=>({t:p.innerText.replace(/\\s+/g,' ').trim(), w:+p.getBoundingClientRect().width.toFixed(1), top:+p.getBoundingClientRect().top.toFixed(1)}));
const rows = new Set(items.map(i=>i.top)).size;
const sum = items.reduce((a,b)=>a+b.w,0) + gap*(items.length-1);
return { vw: innerWidth, wrapW:+wrap.getBoundingClientRect().width.toFixed(1), gap, rows, sum:+sum.toFixed(1), items };
})()`);
console.log(`\n【视口 ${W}px】容器 ${r.wrapW}px | gap ${r.gap}px | 实际行数 ${r.rows} | 三胶囊总需 ${r.sum}px | 溢出 ${(r.sum - r.wrapW).toFixed(1)}px`);
r.items.forEach(i=>console.log(` ${String(i.w).padStart(6)}px top=${i.top} "${i.t}"`));
}
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);console.error(stderr.join('').slice(-300));try{chrome.kill();}catch(_){}process.exit(1);});
+41
View File
@@ -0,0 +1,41 @@
// 最坏情况宽度预算:27天×12h=324.0h,¥6318.0(5位数),360px 小屏
const { spawn } = require('child_process');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:8388/index.html';
const chrome = spawn(CHROME, ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9237', `--user-data-dir=${OUT_DIR}/.chrome-pill2`,'--window-size=360,900','about:blank'
], { stdio:['ignore','ignore','pipe'] });
const stderr=[]; chrome.stderr.on('data',d=>stderr.push(d.toString()));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9237/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('CDP fail '+stderr.join('').slice(-300));}
(async()=>{
const ws=new WebSocket(await getWs()); let id=0; const pending=new Map();
let or; const op=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data); if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}};
await op;
const send=(me,p={})=>new Promise(r=>{const i=++id;pending.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:p}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:1,mobile:true});
await send('Page.navigate',{url:URL}); await sleep(1300);
// 满月 27 天 × 12h = 324.0h → 工时收入 ¥6318.0
await ev(`(function(){const rec={};for(let d=1;d<=27;d++)rec['2026-09-'+String(d).padStart(2,'0')]={status:'work',shift_type:'night',hours:12,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};localStorage.setItem('work_records',JSON.stringify(rec));return 1;})()`);
await send('Page.navigate',{url:URL}); await sleep(1400);
const r = await ev(`(function(){
const wrap=document.querySelector('header .flex.flex-wrap');
const ps=Array.from(document.querySelectorAll('header .pill'));
const gap=parseFloat(getComputedStyle(wrap).gap)||6;
const items=ps.map(p=>({t:p.innerText.replace(/\\s+/g,' ').trim(),w:+p.getBoundingClientRect().width.toFixed(1),top:+p.getBoundingClientRect().top.toFixed(1)}));
const cs=getComputedStyle(ps[0]);
return { wrapW:+wrap.getBoundingClientRect().width.toFixed(1), gap, rows:new Set(items.map(i=>i.top)).size,
sum:+(items.reduce((a,b)=>a+b.w,0)+gap*(items.length-1)).toFixed(1),
pillPad:cs.padding, pillFont:cs.fontSize, pillGap:cs.gap, items };
})()`);
console.log('【最坏情况 @360px】');
console.log(` 容器 ${r.wrapW}px | wrapper gap ${r.gap}px | pill padding ${r.pillPad} | font ${r.pillFont} | icon gap ${r.pillGap}`);
console.log(` 三胶囊总需 ${r.sum}px → 溢出 ${(r.sum-r.wrapW).toFixed(1)}px | 实际行数 ${r.rows}`);
r.items.forEach(i=>console.log(` ${String(i.w).padStart(6)}px "${i.t}"`));
console.log(`\n 需要压缩至少 ${(r.sum-r.wrapW).toFixed(1)}px(外加安全余量)`);
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);console.error(stderr.join('').slice(-300));try{chrome.kill();}catch(_){}process.exit(1);});
+78
View File
@@ -0,0 +1,78 @@
// 实测自动重试:用请求拦截模拟「Vue 前 N 次取不到」,看页面能否自己恢复。
//
// 场景对应真实故障:服务端日志里失败的请求压根没有记录,说明请求没到服务器(链路丢包)。
// 这里用 request.abort() 复现同一效果 —— 浏览器侧看到的就是「拿不到这个文件」。
const puppeteer = require('/root/.openclaw/workspace/web_tasks/node_modules/puppeteer');
const fs = require('fs');
const URL = 'http://127.0.0.1:9522/';
const VUE = 'vue.global.prod.js';
async function run(label, failTimes, expect) {
const profile = `/tmp/wht-retry-${failTimes}`;
fs.rmSync(profile, { recursive: true, force: true });
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium',
headless: 'new',
userDataDir: profile,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844, isMobile: true, deviceScaleFactor: 2 });
let blocked = 0, served = 0;
await page.setRequestInterception(true);
page.on('request', r => {
if (r.url().includes(VUE)) {
if (blocked < failTimes) { blocked++; return r.abort('failed'); }
served++;
}
r.continue();
});
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
// 自动重试有 800ms 递增退避 + reload,留足时间
await new Promise(r => setTimeout(r, 6000));
const s = await page.evaluate(() => {
const card = document.getElementById('boot-fail');
return {
vue: typeof Vue !== 'undefined',
cardShown: card ? !card.hidden : false,
title: document.getElementById('boot-title')?.textContent || null,
detail: document.getElementById('boot-detail')?.textContent || null,
btnHidden: document.getElementById('boot-retry')?.hidden ?? null,
mounted: !!document.querySelector('#app')?.innerText?.trim().length,
retryCount: sessionStorage.getItem('wht_boot_retry')
};
});
const pass = expect(s);
console.log(`\n[${pass ? 'PASS' : 'FAIL'}] ${label}`);
console.log(` 拦截 ${blocked} 次 / 放行 ${served} 次`);
console.log(` Vue=${s.vue} 已挂载=${s.mounted} 卡片显示=${s.cardShown} 重试计数=${s.retryCount}`);
if (s.cardShown) console.log(` 标题="${s.title}"\n 详情="${s.detail}"`);
await page.screenshot({
path: `/root/.openclaw/workspace/work-hours-tracker/png/retry_fail${failTimes}.png`
});
await browser.close();
fs.rmSync(profile, { recursive: true, force: true });
return pass;
}
(async () => {
const results = [];
// 0 次失败:正常路径,卡片不该出现
results.push(await run('正常加载(不拦截)', 0, s => s.vue && s.mounted && !s.cardShown));
// 1 次失败:应自动重试成功,用户看不到失败卡
results.push(await run('丢包 1 次 → 自动恢复', 1, s => s.vue && s.mounted && !s.cardShown));
// 999 次失败:自动重试全败,应显示卡片且按钮可点
results.push(await run('持续丢包 → 显示卡片且不刷新循环', 999,
s => !s.vue && s.cardShown && s.title === '页面没能加载完成' && s.btnHidden === false));
console.log('\n' + '='.repeat(46));
console.log(results.every(Boolean) ? `全部通过(${results.length}/${results.length})` : '有失败项');
console.log('='.repeat(46));
process.exit(results.every(Boolean) ? 0 : 1);
})().catch(e => { console.error('FAIL', e); process.exit(1); });
+103
View File
@@ -0,0 +1,103 @@
// 实测:点击右下角「设置」Tab -> 设置抽屉是否弹出
const { spawn } = require('child_process');
const fs = require('fs');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:9522/index.html';
const chrome = spawn(CHROME, [
'--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9226', `--user-data-dir=${OUT_DIR}/.chrome-test2`,
'--window-size=420,900','about:blank'
], { stdio: ['ignore','ignore','pipe'] });
const stderr = [];
chrome.stderr.on('data', d => stderr.push(d.toString()));
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWsUrl() {
for (let i = 0; i < 40; i++) {
try {
const tabs = await (await fetch('http://127.0.0.1:9226/json/list')).json();
if (tabs.length) return tabs[0].webSocketDebuggerUrl;
} catch (e) {}
await sleep(250);
}
throw new Error('CDP 连接失败: ' + stderr.join('').slice(-400));
}
(async () => {
try {
const wsUrl = await getWsUrl();
const ws = new WebSocket(wsUrl);
let id = 0; const pending = new Map(); const logs = [];
let openRes; const openP = new Promise(r => openRes = r);
ws.onopen = openRes;
ws.onmessage = ev => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
else if (m.method === 'Runtime.consoleAPICalled') logs.push('C ' + (m.params.args||[]).map(a=>a.value??a.description??'').join(' '));
else if (m.method === 'Runtime.exceptionThrown') logs.push('EXC ' + JSON.stringify(m.params.exceptionDetails));
};
await openP;
const send = (method, params={}) => new Promise(res => { const mid=++id; pending.set(mid,res); ws.send(JSON.stringify({id:mid,method,params})); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
if (r.result && r.result.exceptionDetails) return { __err: JSON.stringify(r.result.exceptionDetails) };
return r.result && r.result.result && r.result.result.value;
};
await send('Runtime.enable'); await send('Page.enable');
await send('Page.navigate', { url: URL }); await sleep(2200);
// 定位底部 tab 按钮(文字为「设置」的 <button>)
const probe = await ev(`
(function(){
const btns = Array.from(document.querySelectorAll('nav button'));
const info = btns.map(b => ({ t: (b.innerText||'').trim(), rect: JSON.stringify({x:b.getBoundingClientRect().left,y:b.getBoundingClientRect().top,w:b.getBoundingClientRect().width,h:b.getBoundingClientRect().height}), htmlOk: b.outerHTML.slice(0,100) }));
// 命中最上层元素(判断按钮是否被其他元素遮挡)
const sBtn = btns.find(b => (b.innerText||'').trim() === '设置');
let hitTop = null;
if (sBtn) {
const r = sBtn.getBoundingClientRect();
const cx = r.left + r.width/2, cy = r.top + r.height/2;
const topEl = document.elementFromPoint(cx, cy);
hitTop = topEl ? { tag: topEl.tagName, cls: topEl.className && String(topEl.className).slice(0,80) } : null;
}
return { navBtnCount: btns.length, btns: info, settingsBtnHitTest: hitTop };
})()
`);
console.log('底部导航按钮:', JSON.stringify(probe, null, 2));
// 点击「设置」tab
const clickSetup = await ev(`
new Promise(r => {
const btn = Array.from(document.querySelectorAll('nav button')).find(b => (b.innerText||'').trim() === '设置');
if (!btn) return r({ err: 'no settings btn' });
btn.click();
setTimeout(() => {
const z50 = document.querySelectorAll('.z-50').length;
const overlay = document.querySelector('.fixed.inset-0.z-50');
r({
z50,
overlayPresent: !!overlay,
overlayText: overlay ? (overlay.innerText||'').replace(/\\s+/g,' ').slice(0,60) : null,
bodyHasJiangchou: document.body.innerText.includes('薪酬规则')
});
}, 450);
})
`);
console.log('点击设置 Tab 后:', JSON.stringify(clickSetup, null, 2));
const shot = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(OUT_DIR + '/tab_settings_click.png', Buffer.from(shot.result.data, 'base64'));
console.log('截图 tab_settings_click.png');
console.log('\n=== 控制台 ===');
console.log(logs.length ? logs.join('\n') : '(无)');
ws.close(); chrome.kill(); process.exit(0);
} catch (e) {
console.error('FAILED:', e.message); console.error(stderr.join('').slice(-400));
try { chrome.kill(); } catch(_){} process.exit(1);
}
})();
+60
View File
@@ -0,0 +1,60 @@
// 复现排查:用真实浏览器分别从 127.0.0.1 与公网 IP 打开,看 boot-fail 会不会出现
const { spawn } = require('child_process');
const OUT = '/root/.openclaw/workspace/work-hours-tracker/png';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const TARGETS = [
['本机同源', 'http://127.0.0.1:9522/index.html'],
['公网 IP ', 'http://66.235.106.12:9522/index.html']
];
(async () => {
const chrome = spawn('/usr/bin/chromium', [
'--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
'--remote-debugging-port=9264', `--user-data-dir=${OUT}/.chrome-boot`,
'--window-size=360,900', 'about:blank'
], { stdio: ['ignore', 'ignore', 'pipe'] });
const wsUrl = await (async () => {
for (let i = 0; i < 40; i++) {
try { const t = await (await fetch('http://127.0.0.1:9264/json/list')).json(); if (t.length) return t[0].webSocketDebuggerUrl; } catch {}
await sleep(250);
}
throw new Error('CDP 起不来');
})();
const ws = new WebSocket(wsUrl);
let id = 0; const pnd = new Map();
await new Promise(r => { ws.onopen = r; });
const reqs = [];
ws.onmessage = e => {
const m = JSON.parse(e.data);
if (m.id && pnd.has(m.id)) { pnd.get(m.id)(m); pnd.delete(m.id); }
if (m.method === 'Network.responseReceived') {
reqs.push(`${m.params.response.status} ${m.params.response.url.replace(/^https?:\/\/[^/]+/, '')}`);
}
if (m.method === 'Network.loadingFailed') reqs.push(`FAILED ${m.params.errorText}`);
};
const send = (method, params = {}) => new Promise(r => { const i = ++id; pnd.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); });
const ev = async x => {
const r = await send('Runtime.evaluate', { expression: x, awaitPromise: true, returnByValue: true });
return r.result && r.result.result && r.result.result.value;
};
await send('Runtime.enable'); await send('Page.enable'); await send('Network.enable');
for (const [label, url] of TARGETS) {
reqs.length = 0;
await send('Page.navigate', { url }); await sleep(2600);
console.log(`\n=== ${label} ${url} ===`);
console.log(' Vue 是否加载 =', await ev(`typeof Vue`));
console.log(' boot-fail 是否显示 =', await ev(`(function(){const b=document.getElementById('boot-fail');return b?!b.hidden:'元素不存在(旧版页面)';})()`));
console.log(' #app 是否存在 =', await ev(`!!document.getElementById('app')`));
console.log(' 首屏文本 =', String(await ev(`document.body.innerText.replace(/\\n+/g,' | ').slice(0,110)`)));
console.log(' 资源请求:');
reqs.forEach(r => console.log(' ' + r));
}
ws.close(); chrome.kill();
process.exit(0);
})().catch(e => { console.error('FAILED:', e.message); process.exit(1); });
+114
View File
@@ -0,0 +1,114 @@
// 验收:两套口径分区(挣得 / 进账)+ 归属期徽章 + 结转标注
const { spawn } = require('child_process');
const fs = require('fs');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:8388/index.html';
const chrome = spawn(CHROME, [
'--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9232', `--user-data-dir=${OUT_DIR}/.chrome-caliber`,
'--window-size=420,1200','about:blank'
], { stdio: ['ignore','ignore','pipe'] });
const stderr = []; chrome.stderr.on('data', d => stderr.push(d.toString()));
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWsUrl() {
for (let i=0;i<40;i++){ try{ const t=await(await fetch('http://127.0.0.1:9232/json/list')).json(); if(t.length)return t[0].webSocketDebuggerUrl; }catch(e){} await sleep(250);} throw new Error('CDP fail: '+stderr.join('').slice(-400));
}
(async () => {
const ws = new WebSocket(await getWsUrl());
let id=0; const pending=new Map(); const logs=[];
let openRes; const openP=new Promise(r=>openRes=r); ws.onopen=openRes;
ws.onmessage=e=>{ const m=JSON.parse(e.data);
if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}
else if(m.method==='Runtime.consoleAPICalled'){const t=(m.params.args||[]).map(a=>a.value??a.description??'').join(' '); if(!/tailwindcss/.test(t))logs.push('C '+t);}
else if(m.method==='Runtime.exceptionThrown')logs.push('EXC '+JSON.stringify(m.params.exceptionDetails).slice(0,300)); };
await openP;
const send=(me,p={})=>new Promise(r=>{const i2=++id;pending.set(i2,r);ws.send(JSON.stringify({id:i2,method:me,params:p}));});
const ev=async(x)=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});
if(r.result&&r.result.exceptionDetails)return{__err:JSON.stringify(r.result.exceptionDetails).slice(0,300)};
return r.result&&r.result.result&&r.result.result.value;};
const shot=async(n)=>{const s=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true});fs.writeFileSync(OUT_DIR+'/'+n,Buffer.from(s.result.data,'base64'));};
await send('Runtime.enable'); await send('Page.enable');
await send('Page.navigate',{url:URL}); await sleep(1200);
// 8月满月打卡(含奖惩)+ 9月上半月打卡,制造出「挣得 ≠ 进账」的典型场景
await ev(`(function(){
const rec = {};
for (let d=1; d<=28; d++) rec['2026-08-'+String(d).padStart(2,'0')] = { status:'work', shift_type: d%3?'day':'night', hours: d%3?8:10, contraband_found: d===5?3:0, contraband_missed: d===9?2:0, other_penalty: d===12?50:null, penalty_reason: d===12?'迟到':'' };
for (let d=1; d<=20; d++) rec['2026-09-'+String(d).padStart(2,'0')] = { status:'work', shift_type:'day', hours:8, contraband_found: d===3?1:0, contraband_missed:0, other_penalty:null, penalty_reason:'' };
localStorage.setItem('work_records', JSON.stringify(rec));
localStorage.setItem('work_month_adjust', JSON.stringify({'2026-08':{status:'final',finalAmount:80,note:'与班长核对',finalizedAt:'2026-09-01T02:00:00.000Z'}}));
return 1;
})()`);
await send('Page.navigate',{url:URL}); await sleep(1800);
// ① 顶部看板:主数字换成「挣得」,副标题带结转说明
const board = await ev(`(function(){
const h = document.querySelector('header').innerText.replace(/\\s+/g,' ');
return { text: h.slice(0, 210) };
})()`);
console.log('① 看板:', JSON.stringify(board));
await shot('c1_board.png');
// ② 汇总页两分区
const sum = await ev(`new Promise(r=>{
Array.from(document.querySelectorAll('nav button')).find(b=>(b.innerText||'').trim()==='汇总').click();
setTimeout(()=>{
const sec = document.querySelector("section[class*='px-4'] , section");
const secs = Array.from(document.querySelectorAll('section'));
const s = secs.find(x=>x.innerText.includes('挣得'));
const t = s ? s.innerText.replace(/\\s+/g,' ') : null;
const badges = Array.from(document.querySelectorAll('.own-badge')).map(b=>({cls:b.className.replace('own-badge ',''),txt:b.textContent.trim()}));
return r({
has挣得: !!(t&&t.includes('9月挣得')), has进账: !!(t&&t.includes('9月进账')),
has何时到手: !!(t&&t.includes('何时到手')),
has合计应得: !!(t&&t.includes('合计应得')), has合计进账: !!(t&&t.includes('合计进账')),
has结转标注: !!(t&&t.includes('结转10月10号发')),
has本月22: !!(t&&t.includes('本月22号发')),
badges,
snippet: t ? t.slice(0, 420) : null
});
}, 500);
})`);
console.log('② 汇总分区:', JSON.stringify(sum, null, 1));
await shot('c2_summary.png');
// ③ 数字自洽校验:挣得 = 工时收入+奖惩净额;进账 = 两笔之和;结转 = payoutNextB
const nums = await ev(`(function(){
const b = document.body.innerText.replace(/\\s+/g,' ');
const g = re => { const m = b.match(re); return m ? m[1] : null; };
return {
工时收入: g(/工时收入\\S*?\\s*确定\\s*¥([\\d,.]+)/),
奖惩净额: g(/奖惩净额\\s*\\S+\\s*([+-]¥[\\d,.]+)/),
合计应得: g(/合计应得\\s*¥([\\d,.]+)/),
上半月已发: g(/9\\/22 上半月工时([^)]*)\\s*¥([\\d,.]+)/),
结转: g(/10\\/10 下半月工时\\+奖惩(结转)\\s*¥([\\d,.]+)/),
合计进账: g(/合计进账\\s*¥([\\d,.]+)/),
看板结转: g(/9月进账 ¥([\\d,.]+)/)
};
})()`);
console.log('③ 数字:', JSON.stringify(nums));
// ④ 文本报告分段
const rpt = await ev(`(function(){
const app = document.querySelector('#app');
return null;
})()`);
const rptText = await ev(`(function(){
// 通过点「复制文本报告」不便读剪贴板,改为直接取按钮上方渲染不到的内容:用 clipboard 拦截
let captured = null;
const orig = navigator.clipboard && navigator.clipboard.writeText;
return new Promise(res=>{
if (navigator.clipboard) navigator.clipboard.writeText = (t)=>{ captured = t; return Promise.resolve(); };
const btn = Array.from(document.querySelectorAll('button')).find(b=>/复制文本报告/.test(b.innerText||''));
btn.click();
setTimeout(()=>{ res(captured); }, 400);
});
})()`);
console.log('④ 报告:\n' + (rptText || '(未捕获)'));
console.log('\n=== 异常/控制台 ===');
console.log(logs.length?logs.join('\n'):'(无异常)');
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{ console.error('FAILED:',e.message); console.error(stderr.join('').slice(-400)); try{chrome.kill();}catch(_){}process.exit(1); });
+64
View File
@@ -0,0 +1,64 @@
// 分别模拟:CDN 完全不可达 / CDN 很慢,看刷新瞬间屏幕上到底是什么
const { spawn } = require('child_process');
const fs = require('fs');
const OUT = __dirname;
const URL_ = 'http://127.0.0.1:9523/index.html';
const chrome = spawn('/usr/bin/chromium', ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--remote-debugging-port=9262',`--user-data-dir=${OUT}/.chrome-cdn`,'--window-size=360,900','about:blank'], { stdio:['ignore','ignore','pipe'] });
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9262/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('cdp');}
(async () => {
const ws = new WebSocket(await getWs()); let id=0; const p=new Map(); let or; const o=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage = e => { const m=JSON.parse(e.data); if(m.id&&p.has(m.id)){p.get(m.id)(m);p.delete(m.id);} };
await o;
const send=(me,pa={})=>new Promise(r=>{const i=++id;p.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:pa}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable'); await send('Network.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:2,mobile:true});
await send('Network.setCacheDisabled',{cacheDisabled:true});
const probe = async (tag) => {
const hasMustache = await ev(`/\\{\\{[^}]*\\}\\}/.test(document.body.innerHTML)`);
const twStyle = await ev(`Array.from(document.querySelectorAll('style')).some(s=>/tailwind|--tw-/.test(s.textContent))`);
const bodyH = await ev(`document.body.scrollHeight`);
const txt = await ev(`document.body.innerText.replace(/\\s+/g,' ').slice(0,150)`);
console.log(` ${tag} 裸模板=${hasMustache?'是':'否'} tailwind样式=${twStyle?'有':'无'} 页高=${bodyH}px`);
console.log(` 屏上文字="${txt}"`);
};
console.log('=== 场景 A:两个 CDN 全部不可达(模拟被墙/断外网)===');
await send('Network.setBlockedURLs',{urls:['*cdn.tailwindcss.com*','*unpkg.com*']});
await send('Page.navigate',{url:URL_}); await sleep(2500);
await probe('2500ms');
let s = await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT+'/fouc_cdn_blocked.png', Buffer.from(s.result.data,'base64'));
console.log(' -> png/fouc_cdn_blocked.png');
console.log('\n=== 场景 B:只有 tailwind 不可达,Vue 正常 ===');
await send('Network.setBlockedURLs',{urls:['*cdn.tailwindcss.com*']});
await send('Page.navigate',{url:URL_}); await sleep(2500);
await probe('2500ms');
s = await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT+'/fouc_tailwind_blocked.png', Buffer.from(s.result.data,'base64'));
console.log(' -> png/fouc_tailwind_blocked.png');
console.log('\n=== 场景 C:只有 Vue 不可达,tailwind 正常 ===');
await send('Network.setBlockedURLs',{urls:['*unpkg.com*']});
await send('Page.navigate',{url:URL_}); await sleep(2500);
await probe('2500ms');
s = await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT+'/fouc_vue_blocked.png', Buffer.from(s.result.data,'base64'));
console.log(' -> png/fouc_vue_blocked.png');
console.log('\n=== 场景 D:CDN 很慢(3G 限速),逐帧看 ===');
await send('Network.setBlockedURLs',{urls:[]});
await send('Network.emulateNetworkConditions',{offline:false,latency:400,downloadThroughput:400*1024/8,uploadThroughput:400*1024/8});
const t0 = Date.now();
send('Page.navigate',{url:URL_});
for (const at of [600, 1200, 2000, 3500, 6000]) {
while (Date.now()-t0 < at) await sleep(20);
await probe(String(at)+'ms');
if (at===1200){ s=await send('Page.captureScreenshot',{format:'png'}); fs.writeFileSync(OUT+'/fouc_slow_1200ms.png', Buffer.from(s.result.data,'base64')); }
}
console.log(' -> png/fouc_slow_1200ms.png');
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+46
View File
@@ -0,0 +1,46 @@
// 日期格子是 div 不是 button,上一版选择器点不中。这版按 .calendar-cell 的父 div 点。
const { spawn } = require('child_process');
const OUT='/root/.openclaw/workspace/work-hours-tracker/png';
const URL='http://127.0.0.1:9522/index.html';
const chrome=spawn('/usr/bin/chromium',['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--remote-debugging-port=9254',`--user-data-dir=${OUT}/.chrome-drawer2`,'--window-size=360,900','about:blank'],{stdio:['ignore','ignore','pipe']});
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9254/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('cdp');}
(async()=>{
const ws=new WebSocket(await getWs());let id=0;const p=new Map();let or;const o=new Promise(r=>or=r);ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id&&p.has(m.id)){p.get(m.id)(m);p.delete(m.id);}};await o;
const send=(me,pa={})=>new Promise(r=>{const i=++id;p.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:pa}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable');await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:2,mobile:true});
await send('Page.navigate',{url:URL});await sleep(1300);
await ev(`(function(){localStorage.clear();localStorage.setItem('work_records',JSON.stringify({
'2026-09-08':{status:'work',shift_type:'day',hours:8,contraband_found:1,contraband_missed:0,other_penalty:null,penalty_reason:''}
}));return 1;})()`);
const openDay = async (d) => {
await send('Page.navigate',{url:URL});await sleep(1500);
const hit = await ev(`(function(){
const cells=Array.from(document.querySelectorAll('.grid.grid-cols-7 > div'));
const t=cells.find(c=>{const n=c.querySelector('.day-num, .rounded-full');return n && n.textContent.trim()==='${d}';});
if(!t) return 'NOT_FOUND';
t.click(); return 'CLICKED';
})()`);
await sleep(700);
const head = await ev(`(function(){
const ds=Array.from(document.querySelectorAll('.fixed.inset-0.z-50'));
const d=ds.find(x=>/\\u6253\\u5361/.test(x.innerText));
return d ? d.innerText.split('\\n').filter(s=>s.trim()).slice(0,5).join(' | ') : 'NO_DRAWER';
})()`);
return { hit, head };
};
const r8 = await openDay(8);
console.log('8日(上半月) click=%s\n 抽屉前5行 = %s', r8.hit, JSON.stringify(r8.head));
const s=await send('Page.captureScreenshot',{format:'png'});
require('fs').writeFileSync(OUT+'/drawer_no_subtitle.png',Buffer.from(s.result.data,'base64'));
const r20 = await openDay(20);
console.log('20日(下半月) click=%s\n 抽屉前5行 = %s', r20.hit, JSON.stringify(r20.head));
console.log('全页残留旧副标题 =', await ev(`/工时计入|奖惩定稿后随次月/.test(document.body.innerText)`));
console.log('screenshot -> png/drawer_no_subtitle.png');
ws.close();chrome.kill();process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+41
View File
@@ -0,0 +1,41 @@
// 复现「刷新瞬间闪出未渲染内容」:在导航后极早的时间点连续抓 DOM 文本与截图
const { spawn } = require('child_process');
const fs = require('fs');
const OUT = __dirname;
const URL_ = 'http://127.0.0.1:9523/index.html';
const chrome = spawn('/usr/bin/chromium', ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--remote-debugging-port=9261',`--user-data-dir=${OUT}/.chrome-fouc`,'--window-size=360,900','about:blank'], { stdio:['ignore','ignore','pipe'] });
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9261/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('cdp');}
(async () => {
const ws = new WebSocket(await getWs()); let id=0; const p=new Map(); let or; const o=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage = e => { const m=JSON.parse(e.data); if(m.id&&p.has(m.id)){p.get(m.id)(m);p.delete(m.id);} };
await o;
const send=(me,pa={})=>new Promise(r=>{const i=++id;p.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:pa}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable'); await send('Network.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:2,mobile:true});
// 记录每个资源的耗时
const timings = [];
ws.addEventListener('message', e => {
const m = JSON.parse(e.data);
if (m.method === 'Network.responseReceived') timings.push({ url: m.params.response.url, status: m.params.response.status, t: Date.now() });
});
console.log('=== 冷启动(无缓存)逐帧抓取 ===');
await send('Network.setCacheDisabled',{cacheDisabled:true});
const t0 = Date.now();
send('Page.navigate',{url:URL_});
for (const at of [80, 200, 400, 800, 1500, 2500]) {
while (Date.now() - t0 < at) await sleep(20);
const txt = await ev(`(document.getElementById('app')||document.body).innerText.replace(/\\s+/g,' ').slice(0,110)`);
const hasMustache = await ev(`/\\{\\{[^}]*\\}\\}/.test(document.body.innerHTML)`);
const tw = await ev(`!!window.tailwind`);
const vue = await ev(`!!window.Vue`);
console.log(` ${String(at).padStart(4)}ms 裸模板=${hasMustache?'是':'否'} tailwind=${tw?'已载':'未载'} vue=${vue?'已载':'未载'} 屏上文字="${txt}"`);
if (at === 400) { const s = await send('Page.captureScreenshot',{format:'png'}); fs.writeFileSync(OUT+'/fouc_400ms.png', Buffer.from(s.result.data,'base64')); }
}
console.log('\n=== 各资源加载耗时(相对导航开始)===');
for (const r of timings) console.log(` ${String(r.t-t0).padStart(5)}ms ${r.status} ${r.url.slice(0,72)}`);
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+76
View File
@@ -0,0 +1,76 @@
// 修复后复测:冷启动逐帧 + 完全断外网 + 3G 限速,确认任何时刻都不闪裸模板
const { spawn } = require('child_process');
const fs = require('fs');
const OUT = __dirname;
const URL_ = 'http://127.0.0.1:9522/index.html';
const chrome = spawn('/usr/bin/chromium', ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--remote-debugging-port=9263',`--user-data-dir=${OUT}/.chrome-fixed`,'--window-size=360,900','about:blank'], { stdio:['ignore','ignore','pipe'] });
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9263/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('cdp');}
(async () => {
const ws = new WebSocket(await getWs()); let id=0; const p=new Map(); let or; const o=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage = e => { const m=JSON.parse(e.data); if(m.id&&p.has(m.id)){p.get(m.id)(m);p.delete(m.id);} };
await o;
const send=(me,pa={})=>new Promise(r=>{const i=++id;p.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:pa}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable'); await send('Network.enable'); await send('Log.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:2,mobile:true});
await send('Network.setCacheDisabled',{cacheDisabled:true});
const reqs=[]; const errs=[];
ws.addEventListener('message', e => { const m=JSON.parse(e.data);
if(m.method==='Network.responseReceived') reqs.push(`${m.params.response.status} ${m.params.response.url}`);
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error') errs.push(m.params.entry.text); });
// 可见的裸模板:只算真正显示在屏上的(v-cloak 隐藏的不算)
const VISIBLE_MUSTACHE = `(function(){
const app=document.getElementById('app');
if(!app) return 'NO_APP';
const cs=getComputedStyle(app);
if(cs.visibility==='hidden'||cs.display==='none') return 'HIDDEN';
return /\\{\\{[^}]*\\}\\}/.test(app.innerHTML) ? 'LEAK' : 'CLEAN';
})()`;
const probe = async tag => {
const st = await ev(VISIBLE_MUSTACHE);
const txt = await ev(`document.body.innerText.replace(/\\s+/g,' ').slice(0,80)`);
const styled = await ev(`(function(){const a=document.getElementById('app');return a?getComputedStyle(a).maxWidth:'-'})()`);
const flag = st==='LEAK' ? '❌ 屏上出现裸模板' : st==='HIDDEN' ? '✅ 已遮住(v-cloak)' : '✅ 已渲染';
console.log(` ${tag.padStart(6)} ${flag} app.maxWidth=${styled} "${txt}"`);
return st;
};
console.log('=== 场景1:冷启动逐帧(本地资源,无缓存)===');
let leak=false; const t0=Date.now();
send('Page.navigate',{url:URL_});
for (const at of [50,100,200,300,500,800,1500]) {
while(Date.now()-t0<at) await sleep(10);
if (await probe(at+'ms')==='LEAK') leak=true;
}
let s=await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT+'/fixed_cold.png', Buffer.from(s.result.data,'base64'));
console.log('\n=== 场景2:完全断外网(原先这里只剩裸模板)===');
await send('Network.setBlockedURLs',{urls:['*cdn.tailwindcss.com*','*unpkg.com*','*://*.googleapis.com/*']});
await send('Page.navigate',{url:URL_}); await sleep(2000);
if (await probe('2000ms')==='LEAK') leak=true;
s=await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT+'/fixed_offline.png', Buffer.from(s.result.data,'base64'));
console.log('\n=== 场景3:3G 限速逐帧 ===');
await send('Network.setBlockedURLs',{urls:[]});
await send('Network.emulateNetworkConditions',{offline:false,latency:400,downloadThroughput:400*1024/8,uploadThroughput:400*1024/8});
const t1=Date.now(); send('Page.navigate',{url:URL_});
for (const at of [300,800,1500,3000,5000,8000]) {
while(Date.now()-t1<at) await sleep(20);
if (await probe(at+'ms')==='LEAK') leak=true;
}
s=await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT+'/fixed_slow.png', Buffer.from(s.result.data,'base64'));
console.log('\n=== 全部网络请求 ===');
[...new Set(reqs)].forEach(r=>console.log(' '+r));
console.log('\n=== 控制台错误 ===');
console.log(errs.length? errs.slice(0,5).map(x=>' '+x).join('\n') : ' 无');
console.log('\n结论:' + (leak ? '❌ 仍有裸模板闪现' : '✅ 三个场景下均未出现裸模板'));
ws.close(); chrome.kill(); process.exit(leak?1:0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+36
View File
@@ -0,0 +1,36 @@
// 渲染实测:满勤达标 + 奖惩定稿 → 奖惩卡尾行「计入应得合计」= 满勤 + 奖惩
const { spawn } = require('child_process');
const OUT='/root/.openclaw/workspace/work-hours-tracker/png';
const URL='http://127.0.0.1:9522/index.html';
const chrome=spawn('/usr/bin/chromium',['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--remote-debugging-port=9252',`--user-data-dir=${OUT}/.chrome-fullsum`,'--window-size=360,900','about:blank'],{stdio:['ignore','ignore','pipe']});
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9252/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('cdp');}
(async()=>{
const ws=new WebSocket(await getWs());let id=0;const p=new Map();let or;const o=new Promise(r=>or=r);ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id&&p.has(m.id)){p.get(m.id)(m);p.delete(m.id);}};await o;
const send=(me,pa={})=>new Promise(r=>{const i=++id;p.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:pa}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable');await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:2,mobile:true});
await send('Page.navigate',{url:URL});await sleep(1300);
// 27天×8h=216h 满勤,其中5日查获5件(+50),定稿 +50
await ev(`(function(){localStorage.clear();const rec={};
for(let d=1;d<=27;d++)rec['2026-09-'+String(d).padStart(2,'0')]={status:'work',shift_type:'day',hours:8,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};
rec['2026-09-05'].contraband_found=5;
localStorage.setItem('work_records',JSON.stringify(rec));
localStorage.setItem('work_month_adjust',JSON.stringify({'2026-09':{status:'final',finalAmount:50,note:'组长核定',finalizedAt:'2026-10-01T09:00:00.000Z'}}));
return 1;})()`);
await send('Page.navigate',{url:URL});await sleep(1500);
console.log('打卡页顶部 =',JSON.stringify(await ev(`document.querySelector('header').innerText.replace(/\\n/g,' | ')`)));
await ev(`(function(){const b=Array.from(document.querySelectorAll('button')).find(x=>/汇总/.test(x.innerText));if(b)b.click();return 1;})()`);
await sleep(600);
const s=await ev(`(function(){const c=Array.from(document.querySelectorAll('.card'));
const a=c.find(x=>/总劳动应得/.test(x.innerText)),r=c.find(x=>/奖惩记录/.test(x.innerText));
return {earned:a?a.innerText.replace(/\\n/g,' | '):null, review:r?r.innerText.replace(/\\n/g,' | '):null};})()`);
console.log('应得卡 =',JSON.stringify(s.earned));
console.log('奖惩卡 =',JSON.stringify(s.review));
const shot=await send('Page.captureScreenshot',{format:'png'});
require('fs').writeFileSync(OUT+'/review_full_final.png',Buffer.from(shot.result.data,'base64'));
console.log('screenshot -> png/review_full_final.png');
ws.close();chrome.kill();process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+310
View File
@@ -0,0 +1,310 @@
// 独立验证「首次登录」的真实 UI 路径(doLogin),不走底层 pullAll。
//
// 为什么要单独测:verify_sync_e2e.js 的 C 步直接调 window.WHTSync.pullAll(),
// 而这个底层方法的语义本身就是「用服务器覆盖本机」—— 它把本地 2 天冲掉是预期行为,
// 证明不了 UI 登录路径安全。UI 走 doLogin,必须用真实点击路径独立验证。
//
// 六个场景(全部走「填表单 + 点登录按钮」):
// A 远端空 + 本地有 → 本地不能丢,且应上传
// B 远端有 + 本地空 → 应拉全量
// C 远端有 + 本地有(不同天) → 双向并集,两边都不能丢
// D 同一天内容不同 → 弹合并窗;选「保留本机」
// E 同一天内容不同 → 弹合并窗;选「用服务器版本」
// F 同一天内容不同 → 选「先不处理」→ 必须冻结自动同步,数据一动不动
//
// 前置:server 跑在 9524(ALLOW_REGISTER=1,独立 DB /tmp/wht-login.db),静态页同源
const { spawn } = require('child_process');
const fs = require('fs');
const OUT = '/root/.openclaw/workspace/work-hours-tracker/png';
const BASE = 'http://127.0.0.1:9524';
const PAGE = BASE + '/index.html';
const PW = 'login-test-2026';
const sleep = ms => new Promise(r => setTimeout(r, ms));
let pass = 0, fail = 0;
const ck = (name, got, want) => {
const ok = String(got) === String(want);
ok ? pass++ : fail++;
console.log(` ${ok ? '✅' : '❌'} ${name}`);
if (!ok) console.log(` 实际=${got}\n 期望=${want}`);
};
const REC = (h, sh = 'day', extra = {}) => Object.assign({
status: 'work', shift_type: sh, hours: h,
contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: ''
}, extra);
// ---------- node 侧直连服务端(独立 token,与浏览器会话互不干扰)----------
// 注意:core/api.js 的登录限流是 10 次/分钟/IP,而 clientKey 在没有代理头时统一落到 'local'。
// node 侧预置 6 个账号各登一次 + 浏览器 6 次登录 = 12 次,会把同一个桶打穿(实测 429)。
// 所以 node 侧请求带上各自的 x-forwarded-for,分到独立桶,把限额留给真正要测的 UI 登录。
const ipOf = u => '10.9.' + (users.indexOf(u) + 1) + '.1';
const jf = async (path, opt = {}) => {
const r = await fetch(BASE + path, opt);
return r.json();
};
const H = (u, extra = {}) => Object.assign({ 'content-type': 'application/json', 'x-forwarded-for': ipOf(u) }, extra);
async function reg(u) {
const d = await jf('/api/register', { method: 'POST', headers: H(u), body: JSON.stringify({ username: u, password: PW }) });
if (!d.ok) throw new Error('注册失败 ' + u + ': ' + d.error);
}
async function tok(u) {
const d = await jf('/api/login', { method: 'POST', headers: H(u), body: JSON.stringify({ username: u, password: PW }) });
if (!d.ok) throw new Error('登录失败 ' + u + ': ' + d.error);
return d.token;
}
async function srvPush(u, body) {
const d = await jf('/api/push', {
method: 'POST', headers: H(u, { authorization: 'Bearer ' + T[u] }), body: JSON.stringify(body)
});
if (!d.ok) throw new Error('push 失败: ' + d.error);
return d;
}
async function srvDump(u) {
return jf('/api/data', { headers: { authorization: 'Bearer ' + T[u], 'x-forwarded-for': ipOf(u) } });
}
async function srvDays(u) {
const d = await srvDump(u);
return Object.keys(d.records || {}).sort().join(',') || '(空)';
}
async function srvDay(u, day) {
const d = await srvDump(u);
const r = (d.records || {})[day];
return r ? `${r.shift_type} ${r.hours}h` : '(无)';
}
const users = ['ut.a', 'ut.b', 'ut.c', 'ut.d', 'ut.e', 'ut.f'];
const T = {};
(async () => {
console.log('=== 0. 预置服务端账号与数据 ===');
for (const u of users) await reg(u);
for (const u of users) T[u] = await tok(u);
// B/C:服务端 2 天,与本地不重叠
const seedBC = { records: { '2026-09-01': REC(8), '2026-09-03': REC(10, 'night') } };
await srvPush('ut.b', seedBC);
await srvPush('ut.c', seedBC);
// D/E/F:服务端 9-01 白班 8h(本地同一天将是夜班 12h → 冲突)+ 服务端独有 9-03
const seedConf = { records: { '2026-09-01': REC(8, 'day'), '2026-09-03': REC(10, 'night') } };
for (const u of ['ut.d', 'ut.e', 'ut.f']) await srvPush(u, seedConf);
for (const u of users) console.log(` ${u} 服务端 =`, await srvDays(u));
// ---------- 起浏览器 ----------
const chrome = spawn('/usr/bin/chromium', [
'--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
'--remote-debugging-port=9262', `--user-data-dir=${OUT}/.chrome-login`,
'--window-size=360,900', 'about:blank'
], { stdio: ['ignore', 'ignore', 'pipe'] });
const wsUrl = await (async () => {
for (let i = 0; i < 40; i++) {
try { const t = await (await fetch('http://127.0.0.1:9262/json/list')).json(); if (t.length) return t[0].webSocketDebuggerUrl; } catch {}
await sleep(250);
}
throw new Error('CDP 起不来');
})();
const ws = new WebSocket(wsUrl);
let id = 0; const pnd = new Map();
await new Promise(r => { ws.onopen = r; });
ws.onmessage = e => { const m = JSON.parse(e.data); if (m.id && pnd.has(m.id)) { pnd.get(m.id)(m); pnd.delete(m.id); } };
const send = (method, params = {}) => new Promise(r => { const i = ++id; pnd.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); });
const ev = async expr => {
const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
if (r.result && r.result.exceptionDetails) {
const ed = r.result.exceptionDetails;
return 'JS_ERR: ' + ((ed.exception && ed.exception.description) || ed.text);
}
return r.result && r.result.result && r.result.result.value;
};
await send('Runtime.enable'); await send('Page.enable'); await send('Log.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 360, height: 900, deviceScaleFactor: 2, mobile: true });
const errs = [];
ws.addEventListener('message', e => {
const m = JSON.parse(e.data);
if (m.method === 'Log.entryAdded' && m.params.entry.level === 'error') errs.push(m.params.entry.text);
});
// ---------- 页面操作原语 ----------
const openSettings = () => ev(`(function(){const b=Array.from(document.querySelectorAll('button')).find(x=>x.getAttribute&&x.getAttribute('title')==='设置');if(b){b.click();return 'ok';}return 'NO_GEAR';})()`);
/** 走真实 UI:开设置抽屉 → 填账号密码 → 点「登录」。waitMs 为登录后等待时长。 */
const uiLogin = async (u, waitMs = 2600) => {
const g = await openSettings();
if (g !== 'ok') throw new Error('齿轮点不到: ' + g);
await sleep(600);
const filled = await ev(`(function(){
const card=Array.from(document.querySelectorAll('.card')).find(c=>/云端同步/.test(c.innerText));
if(!card) return 'NO_CARD';
const ins=card.querySelectorAll('input');
const u=Array.from(ins).find(i=>i.type==='text'&&/用户名/.test(i.placeholder));
const p=Array.from(ins).find(i=>i.type==='password');
if(!u||!p) return 'NO_INPUT';
const set=(el,v)=>{el.value=v;el.dispatchEvent(new Event('input',{bubbles:true}));};
set(u,${JSON.stringify(u)}); set(p,${JSON.stringify(PW)});
return 'ok';
})()`);
if (filled !== 'ok') throw new Error('表单填写失败: ' + filled);
await sleep(250);
const clicked = await ev(`(function(){
const card=Array.from(document.querySelectorAll('.card')).find(c=>/云端同步/.test(c.innerText));
const b=Array.from(card.querySelectorAll('button')).find(x=>x.innerText.trim()==='登录');
if(!b) return 'NO_BTN'; b.click(); return 'ok';
})()`);
if (clicked !== 'ok') throw new Error('登录按钮点不到: ' + clicked);
await sleep(waitMs);
};
const localDays = () => ev(`Object.keys(JSON.parse(localStorage.getItem('work_records')||'{}')).sort().join(',')||'(空)'`);
const localDay = d => ev(`(function(){const r=(JSON.parse(localStorage.getItem('work_records')||'{}'))[${JSON.stringify(d)}];return r?r.shift_type+' '+r.hours+'h':'(无)';})()`);
// toast 只停留两秒就消,事后去读 DOM 必然是空。用 MutationObserver 全程录下来。
const installToastRecorder = () => ev(`(function(){
window.__toasts=[];
const seen=new Set();
const scan=()=>{
document.querySelectorAll('div').forEach(d=>{
const c=d.className;
if(typeof c==='string'&&c.indexOf('bg-slate-800/95')>=0&&c.indexOf('rounded-full')>=0){
const t=(d.innerText||'').trim();
if(t&&!seen.has(t)){seen.add(t);window.__toasts.push(t);}
}
});
};
if(window.__toastRec) window.__toastRec.disconnect();
window.__toastRec=new MutationObserver(scan);
window.__toastRec.observe(document.body,{childList:true,subtree:true,characterData:true});
scan();
return 'ok';
})()`);
const toasts = () => ev(`((window.__toasts||[]).join(' ‖ '))||'(无 toast)'`);
const clearToasts = () => ev(`(function(){window.__toasts=[];return 1;})()`);
const mergeVisible = () => ev(`(function(){const els=Array.from(document.querySelectorAll('h3')).filter(h=>/本机与服务器有冲突/.test(h.innerText));return els.length>0;})()`);
const mergeBody = () => ev(`(function(){const h=Array.from(document.querySelectorAll('h3')).find(x=>/本机与服务器有冲突/.test(x.innerText));if(!h)return'(无弹窗)';return h.closest('div.w-full').innerText.replace(/\\n+/g,' | ');})()`);
const clickMerge = re => ev(`(function(){
const h=Array.from(document.querySelectorAll('h3')).find(x=>/本机与服务器有冲突/.test(x.innerText));
if(!h) return 'NO_DIALOG';
const b=Array.from(h.closest('div.w-full').querySelectorAll('button')).find(x=>${re}.test(x.innerText));
if(!b) return 'NO_BTN'; b.click(); return 'ok';
})()`);
const resetLocal = async (recs) => {
await send('Page.navigate', { url: PAGE }); await sleep(1400);
await ev(`(function(){localStorage.clear();${recs ? `localStorage.setItem('work_records',JSON.stringify(${JSON.stringify(recs)}));` : ''}return 1;})()`);
await send('Page.navigate', { url: PAGE }); await sleep(1700);
await installToastRecorder();
};
// ==================== A ====================
console.log('\n=== A. 远端空 + 本地 2 天 → 本地不能丢,且应上传 ===');
await resetLocal({ '2026-09-20': REC(10, 'night'), '2026-09-21': REC(8) });
console.log(' 登录前本地 =', await localDays());
await uiLogin('ut.a');
console.log(' toast =', await toasts());
ck('A1 登录后本地记录(不能丢)', await localDays(), '2026-09-20,2026-09-21');
ck('A2 服务端已收到本地数据', await srvDays('ut.a'), '2026-09-20,2026-09-21');
ck('A3 pending 归零', await ev(`window.WHTSync.state.pending`), '0');
ck('A4 未误弹合并窗', await mergeVisible(), 'false');
// ==================== B ====================
console.log('\n=== B. 远端 2 天 + 本地空 → 应拉全量 ===');
await resetLocal(null);
console.log(' 登录前本地 =', await localDays());
await uiLogin('ut.b');
console.log(' toast =', await toasts());
ck('B1 拉到远端 2 天', await localDays(), '2026-09-01,2026-09-03');
ck('B2 服务端未被空本地覆盖', await srvDays('ut.b'), '2026-09-01,2026-09-03');
// ==================== C ====================
console.log('\n=== C. 远端 2 天 + 本地 1 天(不同) → 双向并集 ===');
await resetLocal({ '2026-09-25': REC(12, 'night') });
console.log(' 登录前本地 =', await localDays());
await uiLogin('ut.c');
console.log(' toast =', await toasts());
ck('C1 本地应含三天', await localDays(), '2026-09-01,2026-09-03,2026-09-25');
ck('C2 服务端应含三天', await srvDays('ut.c'), '2026-09-01,2026-09-03,2026-09-25');
ck('C3 无冲突不该弹窗', await mergeVisible(), 'false');
// ==================== D ====================
console.log('\n=== D. 同一天内容不同 → 弹窗 + 选「保留本机」===');
await resetLocal({ '2026-09-01': REC(12, 'night'), '2026-09-25': REC(8) });
console.log(' 登录前本地 9-01 =', await localDay('2026-09-01'), ' 服务端 9-01 =', await srvDay('ut.d', '2026-09-01'));
await uiLogin('ut.d', 2000);
ck('D1 弹出合并窗', await mergeVisible(), 'true');
console.log(' 弹窗内容 =', (await mergeBody()).slice(0, 260));
ck('D2 弹窗未擅自改本地', await localDay('2026-09-01'), 'night 12h');
const shotD = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(OUT + '/merge_dialog.png', Buffer.from(shotD.result.data, 'base64'));
ck('D3 点「保留本机」', await clickMerge('/保留本机/'), 'ok');
await sleep(2600);
console.log(' toast =', await toasts());
ck('D4 冲突日保留本机值', await localDay('2026-09-01'), 'night 12h');
ck('D5 服务端冲突日被本机覆盖', await srvDay('ut.d', '2026-09-01'), 'night 12h');
ck('D6 本地含三天(并集)', await localDays(), '2026-09-01,2026-09-03,2026-09-25');
ck('D7 服务端含三天(并集)', await srvDays('ut.d'), '2026-09-01,2026-09-03,2026-09-25');
ck('D8 合并前留了本机快照', await ev(`!!(window.WHTSync.getLocalSnapshot()||{}).records`), 'true');
// ==================== E ====================
console.log('\n=== E. 同一天内容不同 → 选「用服务器版本」===');
await resetLocal({ '2026-09-01': REC(12, 'night'), '2026-09-25': REC(8) });
await uiLogin('ut.e', 2000);
ck('E1 弹出合并窗', await mergeVisible(), 'true');
ck('E2 点「用服务器版本」', await clickMerge('/服务器版本/'), 'ok');
await sleep(2600);
console.log(' toast =', await toasts());
ck('E3 冲突日改用服务器值', await localDay('2026-09-01'), 'day 8h');
ck('E4 本机独有的天仍在(没被覆盖掉)', await localDay('2026-09-25'), 'day 8h');
ck('E5 本地含三天', await localDays(), '2026-09-01,2026-09-03,2026-09-25');
ck('E6 服务端含三天', await srvDays('ut.e'), '2026-09-01,2026-09-03,2026-09-25');
ck('E7 服务端冲突日保持原值', await srvDay('ut.e', '2026-09-01'), 'day 8h');
// ==================== F ====================
console.log('\n=== F. 选「先不处理」→ 必须冻结自动同步 ===');
await resetLocal({ '2026-09-01': REC(12, 'night'), '2026-09-25': REC(8) });
await uiLogin('ut.f', 2000);
ck('F1 弹出合并窗', await mergeVisible(), 'true');
ck('F2 点「先不处理」', await clickMerge('/先不处理/'), 'ok');
await sleep(800);
console.log(' toast =', await toasts());
ck('F3 已置同步冻结标志', await ev(`!!window.WHTSync.state.hold`), 'true');
ck('F4 本地一动不动', await localDays(), '2026-09-01,2026-09-25');
ck('F5 状态胶囊提示冲突', await ev(`(function(){const c=Array.from(document.querySelectorAll('.card')).find(x=>/云端同步/.test(x.innerText));return c?/已暂停同步/.test(c.innerText):'NO_CARD';})()`), 'true');
console.log(' —— 冻结期间做一次本地改动,验证不会偷偷同步 ——');
await ev(`(function(){
const r=JSON.parse(localStorage.getItem('work_records')||'{}');
r['2026-09-28']={status:'work',shift_type:'day',hours:8,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};
localStorage.setItem('work_records',JSON.stringify(r));
window.WHTSync.markDirty('record','2026-09-28');
return 1;})()`);
await sleep(4000); // 远超 1.5s 防抖窗口
ck('F6 改动只攒着不上传', await ev(`window.WHTSync.state.pending`), '1');
ck('F7 服务端未被偷偷写入', await srvDays('ut.f'), '2026-09-01,2026-09-03');
ck('F8 本地 9-01 仍是本机值(远端没覆盖过来)', await localDay('2026-09-01'), 'night 12h');
console.log(' —— 冻结后从同步面板重新进合并 ——');
await openSettings(); await sleep(500);
ck('F9 点「比对本机与服务器」', await ev(`(function(){const c=Array.from(document.querySelectorAll('.card')).find(x=>/云端同步/.test(x.innerText));const b=Array.from(c.querySelectorAll('button')).find(x=>/比对本机与服务器/.test(x.innerText));if(!b)return'NO_BTN';b.click();return 'ok';})()`), 'ok');
await sleep(2200);
ck('F10 合并窗重新弹出', await mergeVisible(), 'true');
ck('F11 选「保留本机」收尾', await clickMerge('/保留本机/'), 'ok');
await sleep(2800);
ck('F12 冻结已解除', await ev(`!!window.WHTSync.state.hold`), 'false');
ck('F13 服务端补齐四天', await srvDays('ut.f'), '2026-09-01,2026-09-03,2026-09-25,2026-09-28');
ck('F14 pending 归零', await ev(`window.WHTSync.state.pending`), '0');
const shot = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(OUT + '/login_merge_done.png', Buffer.from(shot.result.data, 'base64'));
console.log('\n=== 控制台错误 ===');
console.log(errs.length ? errs.slice(0, 6).join('\n') : ' 无');
console.log(`\n结果:通过 ${pass} / 失败 ${fail}`);
ws.close(); chrome.kill();
process.exit(fail > 0 ? 1 : 0);
})().catch(e => { console.error('FAILED:', e.message); process.exit(1); });
+150
View File
@@ -0,0 +1,150 @@
// 验收:发薪日标记 + 奖惩终稿修正入口
const { spawn } = require('child_process');
const fs = require('fs');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:8388/index.html';
const chrome = spawn(CHROME, [
'--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9231', `--user-data-dir=${OUT_DIR}/.chrome-verify`,
'--window-size=420,1000','about:blank'
], { stdio: ['ignore','ignore','pipe'] });
const stderr = []; chrome.stderr.on('data', d => stderr.push(d.toString()));
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWsUrl() {
for (let i=0;i<40;i++){ try{ const t=await(await fetch('http://127.0.0.1:9231/json/list')).json(); if(t.length)return t[0].webSocketDebuggerUrl; }catch(e){} await sleep(250);} throw new Error('CDP fail: '+stderr.join('').slice(-400));
}
(async () => {
const wsUrl = await getWsUrl();
const ws = new WebSocket(wsUrl);
let id=0; const pending=new Map(); const logs=[];
let openRes; const openP=new Promise(r=>openRes=r); ws.onopen=openRes;
ws.onmessage=e=>{ const m=JSON.parse(e.data);
if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}
else if(m.method==='Runtime.consoleAPICalled'){const t=(m.params.args||[]).map(a=>a.value??a.description??'').join(' '); if(!/tailwindcss/.test(t))logs.push('C '+t);}
else if(m.method==='Runtime.exceptionThrown')logs.push('EXC '+JSON.stringify(m.params.exceptionDetails).slice(0,300)); };
await openP;
const send=(me,p={})=>new Promise(r=>{const i2=++id;pending.set(i2,r);ws.send(JSON.stringify({id:i2,method:me,params:p}));});
const ev=async(x)=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});
if(r.result&&r.result.exceptionDetails)return{__err:JSON.stringify(r.result.exceptionDetails).slice(0,300)};
return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
// 预置:2026-08 有打卡+奖惩数据,便于验证上月核对入口
await send('Page.navigate',{url:URL}); await sleep(1200);
await ev(`(function(){
const rec = {};
for (let d=1; d<=28; d++) {
rec['2026-08-'+String(d).padStart(2,'0')] = { status:'work', shift_type: d%3?'day':'night', hours: d%3?8:10, contraband_found: d===5?3:0, contraband_missed: d===9?2:0, other_penalty: d===12?50:null, penalty_reason: d===12?'迟到':'' };
}
for (let d=1; d<=20; d++) {
rec['2026-09-'+String(d).padStart(2,'0')] = { status:'work', shift_type:'day', hours:8, contraband_found: d===3?1:0, contraband_missed:0, other_penalty:null, penalty_reason:'' };
}
localStorage.setItem('work_records', JSON.stringify(rec));
localStorage.removeItem('work_month_adjust');
return 'seeded';
})()`);
await send('Page.navigate',{url:URL}); await sleep(2000);
// ① 日历发薪日标记
const cal = await ev(`(function(){
const marks = Array.from(document.querySelectorAll('.pay-mark')).map(m=>({cls:m.className, txt:m.textContent}));
const ringA = document.querySelectorAll('.pay-ring-a').length;
const ringB = document.querySelectorAll('.pay-ring-b').length;
return { payMarkCount: marks.length, marks: marks.slice(0,6), ringA, ringB };
})()`);
console.log('① 发薪日标记:', JSON.stringify(cal));
const s1 = await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT_DIR+'/v1_calendar.png', Buffer.from(s1.result.data,'base64'));
// ② 点发薪日格子 -> 到账横幅
const banner = await ev(`new Promise(r=>{
const cells = Array.from(document.querySelectorAll('.grid.grid-cols-7.gap-1\\\\.5 > div'));
const target = cells.find(c => c.querySelector('.pay-mark'));
if(!target) return r({err:'no payday cell'});
target.click();
setTimeout(()=>{
const ov = document.querySelector('.fixed.inset-0.z-50');
const txt = ov ? ov.innerText.replace(/\\s+/g,' ') : null;
r({ drawerOpen: !!ov, hasBanner: !!(txt && txt.includes('今日到账')), snippet: txt ? txt.slice(0,110) : null });
}, 420);
})`);
console.log('② 发薪日到账横幅:', JSON.stringify(banner));
const s2 = await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT_DIR+'/v2_payday_drawer.png', Buffer.from(s2.result.data,'base64'));
// 关抽屉
await ev(`(function(){ const b=document.querySelector('.fixed.inset-0.z-50 button'); if(b) b.click(); return 1; })()`);
await sleep(400);
// ③ 汇总页 -> 上月核对入口
const summary = await ev(`new Promise(r=>{
const t = Array.from(document.querySelectorAll('nav button')).find(b=>(b.innerText||'').trim()==='汇总');
t.click();
setTimeout(()=>{
const btns = Array.from(document.querySelectorAll('button')).filter(b=>/核对修正|查看\\/改稿/.test(b.innerText||''));
const tags = Array.from(document.querySelectorAll('.stat-tag')).map(x=>x.textContent.trim());
const body = document.body.innerText.replace(/\\s+/g,' ');
r({ correctBtnCount: btns.length, tags, hasRawReview: body.includes('日常累计'), hasKoujing: body.includes('奖惩口径') });
}, 400);
})`);
console.log('③ 汇总页修正入口:', JSON.stringify(summary));
const s3 = await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT_DIR+'/v3_summary.png', Buffer.from(s3.result.data,'base64'));
// ④ 打开上月核对抽屉,填终稿 80,定稿
const adj = await ev(`new Promise(r=>{
const btn = Array.from(document.querySelectorAll('button')).filter(b=>/核对修正|查看\\/改稿/.test(b.innerText||''))[0];
if(!btn) return r({err:'no correct btn'});
btn.click();
setTimeout(()=>{
const ov = document.querySelector('.fixed.inset-0.z-50');
const txt = ov ? ov.innerText.replace(/\\s+/g,' ') : null;
const input = ov && ov.querySelector('input[type=number]');
r({ open: !!ov, hasRawSum: !!(txt&&txt.includes('累计小计')), prefill: input ? input.value : null, snippet: txt?txt.slice(0,150):null });
}, 420);
})`);
console.log('④ 核对抽屉:', JSON.stringify(adj));
const s4 = await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT_DIR+'/v4_adjust_drawer.png', Buffer.from(s4.result.data,'base64'));
// 填 80 并定稿
const fin = await ev(`new Promise(r=>{
const ov = document.querySelector('.fixed.inset-0.z-50');
const input = ov.querySelector('input[type=number]');
input.value = '80';
input.dispatchEvent(new Event('input',{bubbles:true}));
setTimeout(()=>{
const delta = ov.innerText.replace(/\\s+/g,' ');
const okBtn = Array.from(ov.querySelectorAll('button')).find(b=>/确认定稿/.test(b.innerText||''));
okBtn.click();
setTimeout(()=>{
const store = JSON.parse(localStorage.getItem('work_month_adjust')||'{}');
const body = document.body.innerText.replace(/\\s+/g,' ');
r({ deltaShown: /上调|下调/.test(delta), stored: store, bodyHasFinal: body.includes('已定稿') });
}, 500);
}, 300);
})`);
console.log('⑤ 定稿写入:', JSON.stringify(fin));
const s5 = await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(OUT_DIR+'/v5_after_final.png', Buffer.from(s5.result.data,'base64'));
// ⑥ 定稿后发放口径是否跟着变(切到 9 月看 9月10日到账)
const payout = await ev(`(function(){
const body = document.body.innerText.replace(/\\s+/g,' ');
const m = body.match(/9月10日[^¥]*¥([\\d,.]+)/);
return { snippet: body.slice(body.indexOf('本月到账'), body.indexOf('本月到账')+160), matched: m?m[1]:null };
})()`);
console.log('⑥ 发放口径:', JSON.stringify(payout));
console.log('\n=== 异常/控制台 ===');
console.log(logs.length?logs.join('\n'):'(无异常)');
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{ console.error('FAILED:',e.message); console.error(stderr.join('').slice(-400)); try{chrome.kill();}catch(_){}process.exit(1); });
+48
View File
@@ -0,0 +1,48 @@
const { spawn } = require('child_process');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:9522/index.html';
const chrome = spawn(CHROME, ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9251', `--user-data-dir=${OUT_DIR}/.chrome-newtop`,'--window-size=390,900','about:blank'
], { stdio:['ignore','ignore','pipe'] });
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9251/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('CDP fail');}
const SCENES = {
partial: `(function(){localStorage.clear();const rec={};
rec['2026-09-01']={status:'work',shift_type:'day',hours:8,contraband_found:1,contraband_missed:0,other_penalty:null,penalty_reason:''};
rec['2026-09-02']={status:'work',shift_type:'day',hours:8,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};
rec['2026-09-03']={status:'work',shift_type:'day',hours:6,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};
localStorage.setItem('work_records',JSON.stringify(rec));return 1;})()`,
full: `(function(){localStorage.clear();const rec={};
for(let d=1;d<=27;d++)rec['2026-09-'+String(d).padStart(2,'0')]={status:'work',shift_type:'day',hours:8,contraband_found:(d===5?2:0),contraband_missed:0,other_penalty:null,penalty_reason:''};
localStorage.setItem('work_records',JSON.stringify(rec));return 1;})()`
};
(async()=>{
const ws=new WebSocket(await getWs()); let id=0; const pending=new Map();
let or; const op=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data); if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}};
await op;
const send=(me,p={})=>new Promise(r=>{const i=++id;pending.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:p}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:390,height:900,deviceScaleFactor:2,mobile:true});
await send('Page.navigate',{url:URL}); await sleep(1300);
const fs=require('fs');
for (const [name, script] of Object.entries(SCENES)) {
await ev(script);
await send('Page.navigate',{url:URL}); await sleep(1500);
const t = await ev(`document.querySelector('header').innerText`);
console.log(`\n===== 场景 ${name} =====`);
console.log(t);
const r = await send('Page.captureScreenshot',{format:'png'});
fs.writeFileSync(`${OUT_DIR}/new_top_${name}.png`, Buffer.from(r.result.data,'base64'));
// 汇总页
await ev(`tab && (tab.value='summary')`);
await ev(`(function(){const b=[...document.querySelectorAll('nav button')].find(x=>x.innerText.includes('汇总'));if(b)b.click();return 1;})()`);
await sleep(900);
const r2 = await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true});
fs.writeFileSync(`${OUT_DIR}/new_sum_${name}.png`, Buffer.from(r2.result.data,'base64'));
}
console.log('\nscreenshots: png/new_top_{partial,full}.png, png/new_sum_{partial,full}.png');
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+73
View File
@@ -0,0 +1,73 @@
// 验收:顶部三胶囊必须单行(不折行)。覆盖 320/360/390/414/430 五档 × 普通/最坏两种数据
const { spawn } = require('child_process');
const fs = require('fs');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:8388/index.html';
const chrome = spawn(CHROME, ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9238', `--user-data-dir=${OUT_DIR}/.chrome-pillv`,'--window-size=390,900','about:blank'
], { stdio:['ignore','ignore','pipe'] });
const stderr=[]; chrome.stderr.on('data',d=>stderr.push(d.toString()));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9238/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('CDP fail '+stderr.join('').slice(-300));}
let pass=0, fail=0;
const ok=(n,c,x='')=>{ if(c){pass++;console.log(' ✅ '+n+(x?' → '+x:''));} else {fail++;console.log(' ❌ '+n+(x?' → '+x:''));} };
const DATA = {
normal: `(function(){const rec={};for(let d=1;d<=2;d++)rec['2026-09-0'+d]={status:'work',shift_type:'day',hours:d===1?8:6.5,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};localStorage.setItem('work_records',JSON.stringify(rec));return 1;})()`,
worst: `(function(){const rec={};for(let d=1;d<=27;d++)rec['2026-09-'+String(d).padStart(2,'0')]={status:'work',shift_type:'night',hours:12,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};localStorage.setItem('work_records',JSON.stringify(rec));return 1;})()`
};
(async()=>{
const ws=new WebSocket(await getWs()); let id=0; const pending=new Map(); const logs=[];
let or; const op=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data);
if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}
else if(m.method==='Runtime.exceptionThrown')logs.push('EXC '+JSON.stringify(m.params.exceptionDetails).slice(0,250));};
await op;
const send=(me,p={})=>new Promise(r=>{const i=++id;pending.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:p}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
const shot=async n=>{const s=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true});fs.writeFileSync(OUT_DIR+'/'+n,Buffer.from(s.result.data,'base64'));};
await send('Runtime.enable'); await send('Page.enable');
const measure = () => ev(`(function(){
const wrap=document.querySelector('header .flex.flex-nowrap');
if(!wrap) return {err:'wrap not found'};
const ps=Array.from(document.querySelectorAll('header .pill'));
const items=ps.map(p=>{const r=p.getBoundingClientRect();return {t:p.innerText.replace(/\\s+/g,' ').trim(),w:+r.width.toFixed(1),top:+r.top.toFixed(1),h:+r.height.toFixed(1)};});
const gap=parseFloat(getComputedStyle(wrap).gap)||0;
const need=items.reduce((a,b)=>a+b.w,0)+gap*(items.length-1);
const wr=wrap.getBoundingClientRect();
return { vw:innerWidth, wrapW:+wr.width.toFixed(1), wrapH:+wr.height.toFixed(1), gap,
rows:new Set(items.map(i=>i.top)).size, need:+need.toFixed(1),
scrollW:wrap.scrollWidth, clientW:wrap.clientWidth,
pillH:items[0]?items[0].h:0, items };
})()`);
for (const [kind, seed] of Object.entries(DATA)) {
for (const W of [320, 360, 390, 414, 430]) {
await send('Emulation.setDeviceMetricsOverride',{width:W,height:900,deviceScaleFactor:1,mobile:true});
await send('Page.navigate',{url:URL}); await sleep(1100);
await ev(seed);
await send('Page.navigate',{url:URL}); await sleep(1250);
const r = await measure();
console.log(`\n── ${kind==='normal'?'常规数据':'最坏数据(27天×12h → ¥6318.0)'} @ ${W}px`);
if (r.err) { ok('容器存在', false, r.err); continue; }
console.log(` 容器 ${r.wrapW}px | gap ${r.gap}px | 需 ${r.need}px | scrollW ${r.scrollW} / clientW ${r.clientW}`);
r.items.forEach(i=>console.log(` ${String(i.w).padStart(6)}px top=${i.top} "${i.t}"`));
ok(`单行(所有胶囊 top 相同)`, r.rows===1, `rows=${r.rows}`);
ok(`容器高度为单行高(≈${r.pillH}px)`, Math.abs(r.wrapH - r.pillH) < 2, `wrapH=${r.wrapH} pillH=${r.pillH}`);
if (W >= 360) ok(`360+ 无需横滑即可容纳`, r.scrollW <= r.clientW + 1, `scrollW=${r.scrollW} clientW=${r.clientW}`);
if (kind==='worst' && W===360) await shot('p_worst_360.png');
if (kind==='worst' && W===320) await shot('p_worst_320.png');
if (kind==='normal' && W===360) await shot('p_normal_360.png');
}
}
console.log('\n=== 异常 ===');
console.log(logs.length?logs.join('\n'):'(无异常)');
console.log('\n==============================================');
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
console.log('==============================================');
ws.close(); chrome.kill(); process.exit(fail?1:0);
})().catch(e=>{console.error('FAILED:',e.message);console.error(stderr.join('').slice(-300));try{chrome.kill();}catch(_){}process.exit(1);});
+72
View File
@@ -0,0 +1,72 @@
// 验证:奖惩未定稿 → 一律按 ¥0 计(与满勤未达标同口径);定稿后才计入
const { spawn } = require('child_process');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:9522/index.html';
const chrome = spawn(CHROME, ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9251', `--user-data-dir=${OUT_DIR}/.chrome-review`,'--window-size=360,900','about:blank'
], { stdio:['ignore','ignore','pipe'] });
const stderr=[]; chrome.stderr.on('data',d=>stderr.push(d.toString()));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9251/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('CDP fail '+stderr.join('').slice(-300));}
// 场景:9月 3天22h(8+8+6),其中1天查获1件 → 工时 429,查获记录 +10
const RECORDS = `{
'2026-09-01':{status:'work',shift_type:'day',hours:8,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''},
'2026-09-02':{status:'work',shift_type:'day',hours:8,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''},
'2026-09-03':{status:'work',shift_type:'day',hours:6,contraband_found:1,contraband_missed:0,other_penalty:null,penalty_reason:''}
}`;
(async()=>{
const ws=new WebSocket(await getWs()); let id=0; const pending=new Map();
let or; const op=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data); if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}};
await op;
const send=(me,p={})=>new Promise(r=>{const i=++id;pending.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:p}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:2,mobile:true});
await send('Page.navigate',{url:URL}); await sleep(1300);
const readAll = async (label, shot) => {
const snap = await ev(`(function(){
const hb=document.querySelector('header');
// 切到汇总页读「合计应得」卡
const tabs=Array.from(document.querySelectorAll('nav button, footer button, button'));
return { header: hb.innerText.replace(/\\n/g,' | ') };
})()`);
console.log('【'+label+'】');
console.log(' 打卡页顶部 =', JSON.stringify(snap.header));
// 切汇总页
await ev(`(function(){const el=Array.from(document.querySelectorAll('button')).find(b=>/汇总/.test(b.innerText));if(el)el.click();return 1;})()`);
await sleep(500);
const sum = await ev(`(function(){
const cards=Array.from(document.querySelectorAll('.card'));
const c=cards.find(x=>/总劳动应得/.test(x.innerText));
const r=cards.find(x=>/奖惩记录/.test(x.innerText));
return { earned: c?c.innerText.replace(/\\n/g,' | '):null, review: r?r.innerText.replace(/\\n/g,' | '):null };
})()`);
console.log(' 汇总·应得卡 =', JSON.stringify(sum.earned));
console.log(' 汇总·奖惩卡 =', JSON.stringify(sum.review));
if (shot) {
const r=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true});
require('fs').writeFileSync(OUT_DIR+'/'+shot, Buffer.from(r.result.data,'base64'));
console.log(' screenshot -> png/'+shot);
}
// 切回打卡页
await ev(`(function(){const el=Array.from(document.querySelectorAll('button')).find(b=>/打卡/.test(b.innerText));if(el)el.click();return 1;})()`);
await sleep(300);
};
// ① 未定稿
await ev(`(function(){localStorage.clear();localStorage.setItem('work_records',JSON.stringify(${RECORDS}));return 1;})()`);
await send('Page.navigate',{url:URL}); await sleep(1500);
await readAll('未定稿:查获1件只作记录,应得应为 ¥429.0', 'review_draft.png');
// ② 组长审核定稿 +10
await ev(`(function(){localStorage.setItem('work_month_adjust',JSON.stringify({'2026-09':{status:'final',finalAmount:10,note:'组长核定',finalizedAt:'2026-10-01T09:00:00.000Z'}}));return 1;})()`);
await send('Page.navigate',{url:URL}); await sleep(1500);
await readAll('已定稿 +10:应得应为 ¥439.0', 'review_final.png');
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+128
View File
@@ -0,0 +1,128 @@
// 验收:设置改底部抽屉(齿轮 + 底部Tab 触发;三种关闭;不动 tabs 数组)
const { spawn } = require('child_process');
const fs = require('fs');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:8388/index.html';
const chrome = spawn(CHROME, [
'--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9234', `--user-data-dir=${OUT_DIR}/.chrome-drawer`,
'--window-size=420,1000','about:blank'
], { stdio: ['ignore','ignore','pipe'] });
const stderr = []; chrome.stderr.on('data', d => stderr.push(d.toString()));
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWsUrl() {
for (let i=0;i<40;i++){ try{ const t=await(await fetch('http://127.0.0.1:9234/json/list')).json(); if(t.length)return t[0].webSocketDebuggerUrl; }catch(e){} await sleep(250);} throw new Error('CDP fail: '+stderr.join('').slice(-400));
}
let pass=0, fail=0;
const ok = (n,c,extra='') => { if(c){pass++;console.log(' ✅ '+n+(extra?' → '+extra:''));} else {fail++;console.log(' ❌ '+n+(extra?' → '+extra:''));} };
(async () => {
const ws = new WebSocket(await getWsUrl());
let id=0; const pending=new Map(); const logs=[];
let openRes; const openP=new Promise(r=>openRes=r); ws.onopen=openRes;
ws.onmessage=e=>{ const m=JSON.parse(e.data);
if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}
else if(m.method==='Runtime.consoleAPICalled'){const t=(m.params.args||[]).map(a=>a.value??a.description??'').join(' '); if(!/tailwindcss/.test(t))logs.push('C '+t);}
else if(m.method==='Runtime.exceptionThrown')logs.push('EXC '+JSON.stringify(m.params.exceptionDetails).slice(0,300)); };
await openP;
const send=(me,p={})=>new Promise(r=>{const i2=++id;pending.set(i2,r);ws.send(JSON.stringify({id:i2,method:me,params:p}));});
const ev=async(x)=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});
if(r.result&&r.result.exceptionDetails)return{__err:JSON.stringify(r.result.exceptionDetails).slice(0,300)};
return r.result&&r.result.result&&r.result.result.value;};
const shot=async(n)=>{const s=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true});fs.writeFileSync(OUT_DIR+'/'+n,Buffer.from(s.result.data,'base64'));};
// 状态探针:抽屉可见性 / 当前 tab / 底栏指示条
const probe = () => ev(`(function(){
const ov = Array.from(document.querySelectorAll('div.fixed.inset-0.z-50'))
.find(d => /设置/.test((d.querySelector('span.text-lg')||{}).textContent||''));
const navBtns = Array.from(document.querySelectorAll('nav button'));
const activeLabel = (navBtns.find(b => b.querySelector('span.absolute.opacity-100, span.absolute.w-8')) || {});
// 通过指示条 class 判断哪个 tab 处于选中态
let active = null;
navBtns.forEach(b => { const bar=b.querySelector('span.absolute');
if (bar && /opacity-100/.test(bar.className)) active = (b.innerText||'').trim(); });
return {
drawerInDom: !!ov,
drawerVisible: !!(ov && ov.getBoundingClientRect().height > 100),
activeTabLabel: active,
calendarGridVisible: !!(document.querySelector('.grid.grid-cols-7.gap-1\\\\.5')||{}).offsetParent
};
})()`);
const click = (sel) => ev(`(function(){ const e=${sel}; if(!e) return 'NOTFOUND'; e.click(); return 'ok'; })()`);
await send('Runtime.enable'); await send('Page.enable');
await send('Page.navigate',{url:URL}); await sleep(1600);
console.log('── 用例A:tabs 数组契约未被破坏(verify_payroll 用例20 依赖)');
const tabsInfo = await ev(`(function(){
const bs = Array.from(document.querySelectorAll('nav button'));
return { count: bs.length, labels: bs.map(b=>(b.innerText||'').trim()) };
})()`);
ok('底栏 3 个 Tab', tabsInfo.count===3, tabsInfo.count);
ok('顺序 打卡/汇总/设置', JSON.stringify(tabsInfo.labels)==='["打卡","汇总","设置"]', tabsInfo.labels.join(','));
console.log('\n── 用例B:初始态');
let s = await probe();
ok('抽屉未渲染', s.drawerInDom===false);
ok('停在打卡页', s.activeTabLabel==='打卡', s.activeTabLabel);
console.log('\n── 用例C:header 齿轮打开抽屉,且不切 tab');
await click(`Array.from(document.querySelectorAll('header button')).find(b=>b.title==='设置')`);
await sleep(450);
s = await probe();
ok('抽屉已展开', s.drawerVisible===true);
ok('tab 仍是打卡(未整页切换)', s.activeTabLabel==='打卡', s.activeTabLabel);
const formOk = await ev(`(function(){ const t=document.body.innerText; return { 时薪: /基础时薪/.test(t), 数据: /导出备份/.test(t), 滚动容器: !!document.querySelector('.overflow-y-auto.no-scrollbar[class*="72vh"]') }; })()`);
ok('抽屉内含薪酬规则表单', formOk.时薪===true);
ok('抽屉内含数据管理', formOk.数据===true);
ok('内容区有 72vh 滚动容器', formOk.滚动容器===true);
await shot('d1_drawer_open.png');
console.log('\n── 用例D:关闭方式① 点 ✕');
await click(`(function(){ const ov=Array.from(document.querySelectorAll('div.fixed.inset-0.z-50')).find(d=>/设置/.test((d.querySelector('span.text-lg')||{}).textContent||'')); return ov && ov.querySelectorAll('button')[0]; })()`);
await sleep(450);
s = await probe();
ok('抽屉已关闭', s.drawerInDom===false);
ok('tab 仍是打卡', s.activeTabLabel==='打卡', s.activeTabLabel);
console.log('\n── 用例E:底部 Tab「设置」打开抽屉,且不切 tab');
await click(`Array.from(document.querySelectorAll('nav button')).find(b=>(b.innerText||'').trim()==='设置')`);
await sleep(450);
s = await probe();
ok('抽屉已展开', s.drawerVisible===true);
ok('tab 未切到 settings(底栏高亮仍在打卡)', s.activeTabLabel==='打卡', s.activeTabLabel);
console.log('\n── 用例F:关闭方式② 点遮罩(@click.self)');
await click(`Array.from(document.querySelectorAll('div.fixed.inset-0.z-50')).find(d=>/设置/.test((d.querySelector('span.text-lg')||{}).textContent||''))`);
await sleep(450);
s = await probe();
ok('抽屉已关闭', s.drawerInDom===false);
console.log('\n── 用例G:关闭方式③ 点下滑把手');
await click(`Array.from(document.querySelectorAll('nav button')).find(b=>(b.innerText||'').trim()==='设置')`);
await sleep(450);
await click(`(function(){ const ov=Array.from(document.querySelectorAll('div.fixed.inset-0.z-50')).find(d=>/设置/.test((d.querySelector('span.text-lg')||{}).textContent||'')); return ov && ov.querySelector('div.pt-2\\\\.5'); })()`);
await sleep(450);
s = await probe();
ok('抽屉已关闭', s.drawerInDom===false);
console.log('\n── 用例H:抽屉与「汇总」页共存(在汇总页也能开)');
await click(`Array.from(document.querySelectorAll('nav button')).find(b=>(b.innerText||'').trim()==='汇总')`);
await sleep(400);
await click(`Array.from(document.querySelectorAll('nav button')).find(b=>(b.innerText||'').trim()==='设置')`);
await sleep(450);
s = await probe();
ok('汇总页也能开抽屉', s.drawerVisible===true);
ok('底栏高亮仍在汇总', s.activeTabLabel==='汇总', s.activeTabLabel);
await shot('d2_drawer_on_summary.png');
console.log('\n=== 异常/控制台 ===');
console.log(logs.length?logs.join('\n'):'(无异常)');
console.log('\n==============================================');
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
console.log('==============================================');
ws.close(); chrome.kill(); process.exit(fail?1:0);
})().catch(e=>{ console.error('FAILED:',e.message); console.error(stderr.join('').slice(-400)); try{chrome.kill();}catch(_){}process.exit(1); });
+90
View File
@@ -0,0 +1,90 @@
// 端到端:浏览器里跑真实同步流程(登录 → 本地记账自动上传 → 换 origin 拉回)
const { spawn } = require('child_process');
const OUT='/root/.openclaw/workspace/work-hours-tracker/png';
const A='http://127.0.0.1:9524/index.html'; // 新服务:静态 + API 同源
const chrome=spawn('/usr/bin/chromium',['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--remote-debugging-port=9260',`--user-data-dir=${OUT}/.chrome-sync`,'--window-size=360,900','about:blank'],{stdio:['ignore','ignore','pipe']});
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9260/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('cdp');}
(async()=>{
const ws=new WebSocket(await getWs());let id=0;const p=new Map();let or;const o=new Promise(r=>or=r);ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id&&p.has(m.id)){p.get(m.id)(m);p.delete(m.id);}};await o;
const send=(me,pa={})=>new Promise(r=>{const i=++id;p.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:pa}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});
if(r.result&&r.result.exceptionDetails) return 'JS_ERR: '+JSON.stringify(r.result.exceptionDetails.exception&&r.result.exceptionDetails.exception.description||r.result.exceptionDetails.text);
return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable');await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:2,mobile:true});
const errs=[];
await send('Log.enable');
ws.addEventListener('message',e=>{const m=JSON.parse(e.data);
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error')errs.push(m.params.entry.text);});
console.log('=== A. 首次打开:干净 localStorage,未登录 ===');
await send('Page.navigate',{url:A});await sleep(1600);
await ev(`(function(){localStorage.clear();return 1;})()`);
await send('Page.navigate',{url:A});await sleep(1800);
console.log(' WHTSync 已加载 =', await ev(`typeof window.WHTSync`));
console.log(' health 探测到的用户数 =', await ev(`window.WHTSync.state.serverUsers`));
console.log(' 同步状态 =', await ev(`window.WHTSync.state.status`));
console.log('\n=== B. 本地先记 2 天(未登录也能记)===');
await ev(`(function(){
localStorage.setItem('work_records',JSON.stringify({
'2026-09-20':{status:'work',shift_type:'night',hours:10,contraband_found:2,contraband_missed:0,other_penalty:null,penalty_reason:''},
'2026-09-21':{status:'work',shift_type:'day',hours:8,contraband_found:0,contraband_missed:1,other_penalty:null,penalty_reason:''}
}));return 1;})()`);
await send('Page.navigate',{url:A});await sleep(1800);
console.log(' 本地记录天数 =', await ev(`Object.keys(JSON.parse(localStorage.getItem('work_records'))).length`));
console.log(' 打卡页大字 =', await ev(`document.querySelector('header').innerText.replace(/\\n/g,' | ').slice(0,150)`));
console.log('\n=== C. 登录(服务端已有 9/1、9/3 与定稿奖惩)===');
console.log(' login →', await ev(`(async()=>{try{const r=await window.WHTSync.login('kory','kory-work-2026');return 'ok user='+r.user.username+' rev='+r.rev;}catch(e){return 'ERR '+e.message;}})()`));
console.log(' pullAll →', await ev(`(async()=>{const d=await window.WHTSync.pullAll();return 'rev='+d.rev+' 远端天数='+Object.keys(d.records).length;})()`));
await sleep(400);
console.log(' 拉取后本地天数 =', await ev(`Object.keys(JSON.parse(localStorage.getItem('work_records'))).length`));
console.log(' 拉取后本地键 =', await ev(`Object.keys(JSON.parse(localStorage.getItem('work_records'))).sort().join(',')`));
console.log(' 奖惩定稿是否同步下来 =', await ev(`JSON.stringify(JSON.parse(localStorage.getItem('work_month_adjust')||'{}'))`));
console.log('\n=== D. 本地全推上去(合并两边)===');
console.log(' pushAll →', await ev(`(async()=>{const r=await window.WHTSync.pushAll();return 'rev='+r.rev;})()`));
await sleep(300);
console.log('\n=== E. 新记一天 → 自动上传(不手动点同步)===');
await ev(`(function(){
const app=document.querySelector('#app');
// 走真实 UI 路径:点日历 25 号 → 保存
const cells=Array.from(document.querySelectorAll('.grid.grid-cols-7 > div'));
const t=cells.find(c=>{const n=c.querySelector('.day-num, .rounded-full');return n&&n.textContent.trim()==='25';});
t.click(); return 1;})()`);
await sleep(600);
await ev(`(function(){const b=Array.from(document.querySelectorAll('button')).find(x=>/^保存/.test(x.innerText.trim()));if(b)b.click();return b?1:'NO_SAVE_BTN';})()`);
await sleep(500);
console.log(' 保存后 pending =', await ev(`window.WHTSync.state.pending`));
console.log(' 等待自动同步(防抖 1.5s)…');
await sleep(3200);
console.log(' 自动同步后 pending =', await ev(`window.WHTSync.state.pending`));
console.log(' 状态 =', await ev(`window.WHTSync.state.status`), ' rev =', await ev(`window.WHTSync.state.rev`));
console.log('\n=== F. 模拟「换设备」:清空本地后重新登录拉全量 ===');
const tok = await ev(`localStorage.getItem('work_sync_token')`);
await ev(`(function(){localStorage.clear();return 1;})()`);
await send('Page.navigate',{url:A});await sleep(1700);
console.log(' 清空后本地天数 =', await ev(`Object.keys(JSON.parse(localStorage.getItem('work_records')||'{}')).length`));
console.log(' 重新登录 →', await ev(`(async()=>{try{await window.WHTSync.login('kory','kory-work-2026');const d=await window.WHTSync.pullAll();return '拉到 '+Object.keys(d.records).length+' 天, rev='+d.rev;}catch(e){return 'ERR '+e.message}})()`));
await sleep(600);
console.log(' 恢复后本地键 =', await ev(`Object.keys(JSON.parse(localStorage.getItem('work_records'))).sort().join(',')`));
console.log(' 恢复后打卡页 =', await ev(`document.querySelector('header').innerText.replace(/\\n/g,' | ').slice(0,160)`));
console.log('\n=== G. 设置页同步面板 ===');
await ev(`(function(){const b=Array.from(document.querySelectorAll('button[title=设置],button')).find(x=>x.getAttribute&&x.getAttribute('title')==='设置');if(b)b.click();return 1;})()`);
await sleep(700);
console.log(' 面板文本 =', await ev(`(function(){const cs=Array.from(document.querySelectorAll('.card'));const c=cs.find(x=>/云端同步/.test(x.innerText));return c?c.innerText.replace(/\\n+/g,' | '):'NOT_FOUND';})()`));
const shot=await send('Page.captureScreenshot',{format:'png'});
require('fs').writeFileSync(OUT+'/sync_panel.png',Buffer.from(shot.result.data,'base64'));
console.log(' screenshot -> png/sync_panel.png');
console.log('\n=== H. 控制台错误 ===');
console.log(errs.length? errs.slice(0,5).join('\n') : ' 无');
ws.close();chrome.kill();process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+42
View File
@@ -0,0 +1,42 @@
// 验证:打卡页顶部主数字 = 纯工时(429),不含查获奖惩(+10)与未达标满勤奖
const { spawn } = require('child_process');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:9522/index.html';
const chrome = spawn(CHROME, ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9243', `--user-data-dir=${OUT_DIR}/.chrome-topearned`,'--window-size=360,900','about:blank'
], { stdio:['ignore','ignore','pipe'] });
const stderr=[]; chrome.stderr.on('data',d=>stderr.push(d.toString()));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9243/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('CDP fail '+stderr.join('').slice(-300));}
(async()=>{
const ws=new WebSocket(await getWs()); let id=0; const pending=new Map();
let or; const op=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data); if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}};
await op;
const send=(me,p={})=>new Promise(r=>{const i=++id;pending.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:p}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:2,mobile:true});
await send('Page.navigate',{url:URL}); await sleep(1300);
// 先装载同源页面后注入(storage 属于 9522 origin),再重新导航加载
await ev(`(function(){localStorage.clear();const rec={};
rec['2026-09-01']={status:'work',shift_type:'day',hours:8,contraband_found:1,contraband_missed:0,other_penalty:null,penalty_reason:''};
rec['2026-09-02']={status:'work',shift_type:'day',hours:8,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};
rec['2026-09-03']={status:'work',shift_type:'day',hours:6,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};
localStorage.setItem('work_records',JSON.stringify(rec));return 1;})()`);
await send('Page.navigate',{url:URL}); await sleep(1500);
const snap = await ev(`(function(){
const m = document.querySelector('main');
const headerBox = document.querySelector('header');
return { headerText: headerBox ? headerBox.innerText.slice(0,400) : null, pills: Array.from(headerBox.querySelectorAll('.pill')).map(p=>p.innerText.replace(/\\s+/g,' ').trim()) };
})()`);
console.log('【9月 3天22h + 1查获】@360px');
console.log(' 顶部文本 =', JSON.stringify(snap.headerText));
console.log(' 胶囊 =', JSON.stringify(snap.pills));
await send('Page.captureScreenshot',{format:'png'}).then(r=>{
const fs=require('fs'); fs.writeFileSync(OUT_DIR+'/verify_top_earned.png', Buffer.from(r.result.data,'base64'));
console.log(' screenshot -> png/verify_top_earned.png');
});
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);console.error(stderr.join('').slice(-300));try{chrome.kill();}catch(_){}process.exit(1);});
+39
View File
@@ -0,0 +1,39 @@
// 验证:满勤达标后打卡页主数字叠加满勤奖并显示金标
const { spawn } = require('child_process');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:9522/index.html';
const chrome = spawn(CHROME, ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9244', `--user-data-dir=${OUT_DIR}/.chrome-fullbonus`,'--window-size=360,900','about:blank'
], { stdio:['ignore','ignore','pipe'] });
const stderr=[]; chrome.stderr.on('data',d=>stderr.push(d.toString()));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9244/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('CDP fail '+stderr.join('').slice(-300));}
(async()=>{
const ws=new WebSocket(await getWs()); let id=0; const pending=new Map();
let or; const op=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data); if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}};
await op;
const send=(me,p={})=>new Promise(r=>{const i=++id;pending.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:p}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:360,height:900,deviceScaleFactor:2,mobile:true});
await send('Page.navigate',{url:URL}); await sleep(1300);
// 满勤 27 天 × 8h = 216h → 工时收入 4212 + 满勤奖 216 = 4428
await ev(`(function(){localStorage.clear();const rec={};
for(let d=1;d<=27;d++)rec['2026-09-'+String(d).padStart(2,'0')]={status:'work',shift_type:'day',hours:8,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};
localStorage.setItem('work_records',JSON.stringify(rec));return 1;})()`);
await send('Page.navigate',{url:URL}); await sleep(1500);
const snap = await ev(`(function(){
const hb=document.querySelector('header');
return { headerText: hb.innerText.slice(0,320), pills: Array.from(hb.querySelectorAll('.pill')).map(p=>p.innerText.replace(/\\s+/g,' ').trim()) };
})()`);
console.log('【9月 满勤27天×8h】@360px');
console.log(' 顶部文本 =', JSON.stringify(snap.headerText));
console.log(' 胶囊 =', JSON.stringify(snap.pills));
await send('Page.captureScreenshot',{format:'png'}).then(r=>{
const fs=require('fs'); fs.writeFileSync(OUT_DIR+'/verify_top_earned_full.png', Buffer.from(r.result.data,'base64'));
console.log(' screenshot -> png/verify_top_earned_full.png');
});
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+39
View File
@@ -0,0 +1,39 @@
const { spawn } = require('child_process');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:9522/index.html';
const chrome = spawn(CHROME, ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9250', `--user-data-dir=${OUT_DIR}/.chrome-topnew`,'--window-size=390,840','about:blank'
], { stdio:['ignore','ignore','pipe'] });
const stderr=[]; chrome.stderr.on('data',d=>stderr.push(d.toString()));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function getWs(){for(let i=0;i<40;i++){try{const t=await(await fetch('http://127.0.0.1:9250/json/list')).json();if(t.length)return t[0].webSocketDebuggerUrl;}catch(e){}await sleep(250);}throw new Error('CDP fail '+stderr.join('').slice(-300));}
(async()=>{
const ws=new WebSocket(await getWs()); let id=0; const pending=new Map();
let or; const op=new Promise(r=>or=r); ws.onopen=or;
ws.onmessage=e=>{const m=JSON.parse(e.data); if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}};
await op;
const send=(me,p={})=>new Promise(r=>{const i=++id;pending.set(i,r);ws.send(JSON.stringify({id:i,method:me,params:p}));});
const ev=async x=>{const r=await send('Runtime.evaluate',{expression:x,awaitPromise:true,returnByValue:true});return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:390,height:840,deviceScaleFactor:2,mobile:true});
await send('Page.navigate',{url:URL}); await sleep(1300);
// 用户场景:9月3天 22h(8+8+6),其中1件查获
await ev(`(function(){localStorage.clear();const rec={};
rec['2026-09-01']={status:'work',shift_type:'day',hours:8,contraband_found:1,contraband_missed:0,other_penalty:null,penalty_reason:''};
rec['2026-09-02']={status:'work',shift_type:'day',hours:8,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};
rec['2026-09-03']={status:'work',shift_type:'day',hours:6,contraband_found:0,contraband_missed:0,other_penalty:null,penalty_reason:''};
localStorage.setItem('work_records',JSON.stringify(rec));return 1;})()`);
await send('Page.navigate',{url:URL}); await sleep(1500);
const snap = await ev(`(function(){
const hb=document.querySelector('header');
return hb.innerText.slice(0,350);
})()`);
console.log('【9月 3天22h + 1查获】@390px 顶部内容:');
console.log(JSON.stringify(snap, null, 1));
await send('Page.captureScreenshot',{format:'png'}).then(r=>{
const fs=require('fs'); fs.writeFileSync(OUT_DIR+'/verify_top_new.png', Buffer.from(r.result.data,'base64'));
console.log('screenshot -> png/verify_top_new.png');
});
ws.close(); chrome.kill(); process.exit(0);
})().catch(e=>{console.error('FAILED:',e.message);try{chrome.kill();}catch(_){}process.exit(1);});
+65
View File
@@ -0,0 +1,65 @@
// 决定性探针:点设置后,直接读 Vue 应用实例里的 settingsOpen 状态
const { spawn } = require('child_process');
const fs = require('fs');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:8388/index.html';
const chrome = spawn(CHROME, [
'--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9228', `--user-data-dir=${OUT_DIR}/.chrome-test4`,
'--window-size=420,900','about:blank'
], { stdio: ['ignore','ignore','pipe'] });
const stderr = [];
chrome.stderr.on('data', d => stderr.push(d.toString()));
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWsUrl() {
for (let i=0;i<40;i++){ try{ const t=await(await fetch('http://127.0.0.1:9228/json/list')).json(); if(t.length)return t[0].webSocketDebuggerUrl; }catch(e){} await sleep(250);} throw new Error('CDP fail: '+stderr.join('').slice(-400));
}
(async () => {
try {
const wsUrl = await getWsUrl();
const ws = new WebSocket(wsUrl);
let id=0; const pending=new Map(); const logs=[];
let openRes; const openP=new Promise(r=>openRes=r);
ws.onopen=openRes;
ws.onmessage=ev=>{ const m=JSON.parse(ev.data);
if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}
else if(m.method==='Runtime.consoleAPICalled')logs.push('C '+(m.params.args||[]).map(a=>a.value??a.description??'').join(' '));
else if(m.method==='Runtime.exceptionThrown')logs.push('EXC '+JSON.stringify(m.params.exceptionDetails)); };
await openP;
const send=(method,params={})=>new Promise(res=>{const mid=++id;pending.set(mid,res);ws.send(JSON.stringify({id:mid,method,params}));});
const ev=async(expr)=>{const r=await send('Runtime.evaluate',{expression:expr,awaitPromise:true,returnByValue:true}); if(r.result&&r.result.exceptionDetails)return{__err:JSON.stringify(r.result.exceptionDetails)}; return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
await send('Page.navigate',{url:URL}); await sleep(2200);
// 通过 __vue_app__ 拿实例(Vue3 挂在根元素上)
const probe1 = await ev(`
(function(){
const appEl = document.querySelector('#app');
const vue = appEl && (appEl.__vue_app__ || appEl._vnode);
let keys = null;
if (appEl && appEl.__vue_app__) {
// 生产构建通常隐藏,尽力而为
keys = Object.keys(appEl).filter(k=>k.startsWith('__'));
}
// 读所有 ref 状态:尝试从 props/暴露的 setupState
let setupKeys = null;
try {
const inst = appEl.__vue_app__;
// 遍历组件实例
setupKeys = Object.keys(inst).filter(k=>/devtools|app/i.test(k));
} catch(e) { setupKeys = 'ERR '+e.message; }
return {
appElExists: !!appEl,
appKeys: keys,
scan: setupKeys
};
})()
`);
console.log('Vue 实例扫描:', JSON.stringify(probe1, null, 2));
ws.close(); chrome.kill(); process.exit(0);
} catch(e){ console.error('FAILED:',e.message); console.error(stderr.join('').slice(-400)); try{chrome.kill();}catch(_){} process.exit(1); }
})();
+101
View File
@@ -0,0 +1,101 @@
// 决定性探针 v2:深入 __vue_app__ 组件实例树读 settingsOpen/tab
const { spawn } = require('child_process');
const fs = require('fs');
const CHROME = '/usr/bin/chromium';
const OUT_DIR = __dirname;
const URL = 'http://127.0.0.1:8388/index.html';
const chrome = spawn(CHROME, [
'--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage',
'--remote-debugging-port=9229', `--user-data-dir=${OUT_DIR}/.chrome-test5`,
'--window-size=420,900','about:blank'
], { stdio: ['ignore','ignore','pipe'] });
const stderr = [];
chrome.stderr.on('data', d => stderr.push(d.toString()));
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function getWsUrl() {
for (let i=0;i<40;i++){ try{ const t=await(await fetch('http://127.0.0.1:9229/json/list')).json(); if(t.length)return t[0].webSocketDebuggerUrl; }catch(e){} await sleep(250);} throw new Error('CDP fail: '+stderr.join('').slice(-400));
}
(async () => {
try {
const wsUrl = await getWsUrl();
const ws = new WebSocket(wsUrl);
let id=0; const pending=new Map(); const logs=[];
let openRes; const openP=new Promise(r=>openRes=r);
ws.onopen=openRes;
ws.onmessage=ev=>{ const m=JSON.parse(ev.data);
if(m.id&&pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);}
else if(m.method==='Runtime.consoleAPICalled')logs.push('C '+(m.params.args||[]).map(a=>a.value??a.description??'').join(' '));
else if(m.method==='Runtime.exceptionThrown')logs.push('EXC '+JSON.stringify(m.params.exceptionDetails)); };
await openP;
const send=(method,params={})=>new Promise(res=>{const mid=++id;pending.set(mid,res);ws.send(JSON.stringify({id:mid,method,params}));});
const ev=async(expr)=>{const r=await send('Runtime.evaluate',{expression:expr,awaitPromise:true,returnByValue:true}); if(r.result&&r.result.exceptionDetails)return{__err:JSON.stringify(r.result.exceptionDetails)}; return r.result&&r.result.result&&r.result.result.value;};
await send('Runtime.enable'); await send('Page.enable');
await send('Page.navigate',{url:URL}); await sleep(2200);
// 深入 __vue_app__ 拿 setupState(Vue3:app._instance.setupState / 通过组件的 exposed)
const probe = await ev(`
(function(){
const appEl = document.querySelector('#app');
const app = appEl.__vue_app__;
const out = {};
// vue 3.3+: app._instance (root instance)
const root = app._instance;
out.hasRootInstance = !!root;
if (root) {
const setup = root.setupState;
const ctx = root.ctx;
out.setupKeys = setup ? Object.keys(setup) : null;
const get = (name) => {
// ref 或 getter
const v = setup ? setup[name] : undefined;
if (v === undefined && ctx) return ctx[name];
return v;
};
function deref(v) {
if (v === undefined || v === null) return v;
if (typeof v === 'object' && '_value' in v) return v._value; // ref
return v;
}
const sOpen = deref(get('settingsOpen'));
const tabVal = deref(get('tab'));
const iso = deref(get('isDrawerOpen'));
out.settingsOpen = sOpen;
out.isDrawerOpen = iso;
out.tab = tabVal && tabVal._value !== undefined ? tabVal._value : tabVal;
out.tab_raw = tabVal;
} else {
// 尝试通过子组件
const sub = app._container && app._container.__vue_app__;
out.subSearched = !!sub;
}
return out;
})()
`);
console.log('深度读取:', JSON.stringify(probe, null, 2));
// 现在点击设置,再看 settingsOpen
const clickThen = await ev(`
new Promise(r => {
const btn = Array.from(document.querySelectorAll('nav button')).find(b=>(b.innerText||'').trim()==='设置');
if (!btn) return r({err:'no btn'});
btn.click();
setTimeout(() => {
const appEl = document.querySelector('#app');
const root = appEl.__vue_app__._instance;
const setup = root.setupState;
function deref(v){ if(v&&typeof v==='object'&&('_value' in v))return v._value; return v; }
r({
settingsOpen_afterClick: deref(setup.settingsOpen),
tab_afterClick: deref(setup.tab) && deref(setup.tab)._value!==undefined ? deref(setup.tab)._value : deref(setup.tab),
z50: document.querySelectorAll('.z-50').length
});
}, 400);
})
`);
console.log('点击设置后:', JSON.stringify(clickThen, null, 2));
ws.close(); chrome.kill(); process.exit(0);
} catch(e){ console.error('FAILED:',e.message); console.error(stderr.join('').slice(-400)); try{chrome.kill();}catch(_){} process.exit(1); }
})();
+184
View File
@@ -0,0 +1,184 @@
# 工时记账 · 服务端与同步
数据从「只在浏览器 localStorage」升级为「服务器 SQLite/D1 + 本地离线缓存」。
本地仍是唯一真相,断网、没登录都照常记账;同步只是额外把数据推到服务器。
## 目录结构
```
work-hours-tracker/
├── index.html 前端(已内置同步面板)
├── sync.js 客户端同步引擎(不依赖 Vue,可复用到 APP 壳)
├── verify_payroll.js 核算算法用例(220 项)
└── server/
├── core/ ← 平台无关,VPS 与 CF Workers 共用
│ ├── api.js HTTP 接口逻辑(收发标准 Request/Response)
│ ├── auth.js PBKDF2 密码 + SHA-256 令牌(纯 Web Crypto)
│ ├── store.js 存储契约 + 输入校验
│ └── schema.sql 建表语句(SQLite / D1 通用)
├── adapters/
│ ├── sqlite-node.js VPS:node:sqlite(Node 22 内置,零 npm 依赖)
│ └── d1.js Cloudflare D1
├── node-server.js VPS 入口:静态页 + /api/* 同一端口
├── worker.js CF Workers 入口
├── wrangler.toml CF 部署配置
└── test/api.test.js 服务端用例(72 项)
```
换平台只需换适配器,`core/` 一行都不用动。
## VPS 部署(当前方式)
```bash
cd server
npm start # 默认 9523,静态页与 API 同源
```
环境变量:
| 变量 | 默认 | 说明 |
|---|---|---|
| `PORT` | 9523 | 监听端口 |
| `HOST` | 0.0.0.0 | 监听地址 |
| `DB_PATH` | `server/data/work-hours.db` | SQLite 文件位置 |
| `STATIC_DIR` | 项目根 | 静态目录 |
| `ALLOW_REGISTER` | 未设 | 设 `1` 才开放注册 |
首次访问 `http://<地址>:9523` → 设置页 → 云端同步 → 创建首个账号。
**建首个账号后注册自动关闭**,公网暴露也不会被陌生人开号。
跑测试:
```bash
cd server && npm test # 72 项
cd .. && node verify_payroll.js # 220 项
```
### systemd
现有 `work-hours-tracker.service` 是 `python3 -m http.server 9522`,只发静态文件,
且会把 `.bak`、`server/`、数据库一并发出去。切到新服务顺带解决这个问题:
```ini
[Unit]
Description=Work Hours Tracker
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/.openclaw/workspace/work-hours-tracker/server
ExecStart=/usr/bin/node --disable-warning=ExperimentalWarning node-server.js
Environment=PORT=9523
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
## Cloudflare Workers 部署
```bash
cd server
wrangler d1 create work-hours # 把 database_id 填进 wrangler.toml
npm run cf:migrate # 建表
npm run cf:deploy
```
静态资源走 Workers Assets(`wrangler.toml` 的 `[assets]`),API 走同一个 Worker,仍是同源。
已确认可行:`core/` 只用 `Request`/`Response`/`crypto.subtle`/`TextEncoder`,这些 Workers 全都有;
PBKDF2 十万次迭代实测约 17ms,远低于 Workers 的 CPU 时间预算。
## 接口
除 `/api/health`、`/api/register`、`/api/login` 外都需要 `Authorization: Bearer <token>`。
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | `/api/health` | 探活,返回用户数(前端据此决定显示「登录」还是「创建首个账号」) |
| POST | `/api/register` | 注册(默认仅首个账号) |
| POST | `/api/login` | 登录,返回 token 与当前 rev |
| POST | `/api/logout` | 令牌立即失效 |
| GET | `/api/me` | 当前用户与 rev |
| GET | `/api/data` | 全量拉取 |
| GET | `/api/changes?since=N` | 增量拉取(含删除墓碑) |
| POST | `/api/push` | 推送变更,整批原子写入,返回新 rev |
| DELETE | `/api/record/YYYY-MM-DD` | 删单日 |
| DELETE | `/api/adjust/YYYY-MM` | 删单月奖惩 |
| GET | `/api/export` | 导出 JSON,与前端「导出备份」同构,可互相导入 |
## 同步机制
**版本号 rev。** 每个用户一个单调递增计数器,任何写入都 +1,被改动的行打上这个 rev。
客户端记住上次见过的 rev,拉取时只问「比它新的」,不做全量对比。
**墓碑删除。** 删除不物理删行,而是置 `deleted=1`。否则「这天被删了」这件事没法传播到其他设备——
对方只会看到自己有、服务器没有,然后又给推回来。
**先推后拉。** 本地改动记在 dirty 集合里,同步时先 push 再 pull,所以本地改动天然赢,
不会被远端旧值覆盖。dirty 里的键在应用远端数据时会被跳过。
**离线优先。** localStorage 始终是本地真相。没登录、断网、服务器挂了都能照常记账,
改动攒在 dirty 里,等能连上再一次推上去。
**设置合并而非覆盖。** 客户端可能只传改动的字段,服务端与现有值合并,不会把没传的清掉。
**首次登录的冲突处理**(`index.html` 的 `doLogin`):
先 `pullAll` 看服务端有没有数据——服务端为空且本地有数据 → 自动 `pushAll` 上传;
否则拉下来合并。不静默丢任何一边。
## 数据模型
| 表 | 主键 | 说明 |
|---|---|---|
| `users` | id | 含 `rev`,该用户的全局版本号 |
| `sessions` | token_hash | 只存 SHA-256,泄库换不出可用 token |
| `records` | (user_id, day) | day = `2026-09-08` |
| `month_adjust` | (user_id, month) | month = `2026-09`,`status` = draft/final |
| `settings` | user_id | 整份 JSON,字段会迭代所以不拆列 |
## 安全
已做:
- 密码 PBKDF2-SHA256 十万次迭代 + 随机盐,不存明文
- 令牌只存哈希,登出立即失效,30 天过期
- 登录/注册限流(每 IP 每分钟 10 次)
- 用户名不存在时也走一次真实密码校验开销,防止靠响应时间枚举用户名
- 首个账号建立后自动关闭注册
- 逐用户数据隔离(已有用例验证两账号互不可见)
- 静态服务屏蔽 `server/`、`.trash/`、`png/`、`.bak`、`.db`、`.sql`、`verify_*.js`,防目录穿越
- 服务端输入校验:非法状态/班次/工时/件数、`2026-13`、`2026-02-31` 这类假日期全部拒收
未做,公网暴露前需要补:
- **HTTPS**。当前 HTTP 明文,密码与令牌在链路上可见。上公网必须配反代 + 证书,或直接用 CF Workers(自带 HTTPS)。
- 令牌存在 localStorage,XSS 可窃取。当前页面无用户生成内容渲染,风险低。
- 无二次验证、无密码找回。忘记密码只能删库重建。
## 前端接入点
`sync.js` 暴露 `window.WHTSync`:
```js
WHTSync.setApplyHook(fn) // 注册「如何把远端数据写进本地」
WHTSync.markDirty(kind, key) // kind: 'record' | 'adjust' | 'settings'
WHTSync.syncNow() // 手动同步
WHTSync.pullAll() // 服务器覆盖本地
WHTSync.pushAll() // 本地全量上传
WHTSync.onChange(fn) // 订阅状态变化
WHTSync.setApiBase(url) // 跨域部署时指定 API 地址,留空 = 同源
```
`index.html` 里 `persist(dirtyDays)` / `persistAdjust(dirtyMonths)` 接收改动的键并自动标脏,
1.5 秒防抖后上传。回到前台、网络恢复时各补一次同步。
`suppressSync` 标志用于远端数据写回本地时关闭标脏,避免「拉下来 → 又当成本地改动推回去」的回环。
## 做 APP 时
`sync.js` 只依赖 `localStorage` 与 `fetch`,不碰 Vue 也不碰浏览器独有 API。
套 Capacitor / WebView / React Native 时这一层可整块复用,把 `setApiBase()` 指向服务器即可。
`core/` 已经是平台无关的,换 Bun/Deno/其他 runtime 也只需要写一个入口文件。
+146
View File
@@ -0,0 +1,146 @@
// 存储适配器:Cloudflare D1
//
// 与 sqlite-node.js 行为一致。差异只有两点:
// 1. D1 全异步,用 .bind().all()/.first()/.run()
// 2. 原子写入用 env.DB.batch()(整批在一个事务里),而不是手写 BEGIN/COMMIT
// rev 同样靠 `(SELECT rev FROM users WHERE id=?)` 在 SQL 内部取,保证批次内一致。
export class D1Store {
constructor(db) { this.db = db; }
async countUsers() {
const r = await this.db.prepare('SELECT COUNT(*) AS n FROM users').first();
return r ? r.n : 0;
}
async getUserByUsername(username) {
return await this.db.prepare('SELECT * FROM users WHERE username = ?').bind(username).first() || null;
}
async getUserById(id) {
return await this.db.prepare('SELECT * FROM users WHERE id = ?').bind(id).first() || null;
}
async createUser({ username, pwHash, pwSalt, pwIter, createdAt }) {
await this.db.prepare(
'INSERT INTO users (username, pw_hash, pw_salt, pw_iter, rev, created_at) VALUES (?,?,?,?,0,?)'
).bind(username, pwHash, pwSalt, pwIter, createdAt).run();
return this.getUserByUsername(username);
}
async createSession({ tokenHash, userId, createdAt, expiresAt }) {
await this.db.prepare(
'INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?,?,?,?)'
).bind(tokenHash, userId, createdAt, expiresAt).run();
}
async getSessionWithUser(tokenHash) {
const s = await this.db.prepare('SELECT * FROM sessions WHERE token_hash = ?').bind(tokenHash).first();
if (!s) return null;
const u = await this.db.prepare('SELECT * FROM users WHERE id = ?').bind(s.user_id).first();
if (!u) return null;
return { session: s, user: u };
}
async deleteSession(tokenHash) {
await this.db.prepare('DELETE FROM sessions WHERE token_hash = ?').bind(tokenHash).run();
}
async purgeExpiredSessions(nowIso) {
const r = await this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').bind(nowIso).run();
return r.meta ? r.meta.changes : 0;
}
async getRev(userId) {
const r = await this.db.prepare('SELECT rev FROM users WHERE id = ?').bind(userId).first();
return r ? r.rev : 0;
}
async applyPush(userId, { records = [], adjust = [], settings = null }) {
const now = new Date().toISOString();
const NEW_REV = '(SELECT rev FROM users WHERE id = ?)';
const stmts = [this.db.prepare('UPDATE users SET rev = rev + 1 WHERE id = ?').bind(userId)];
const recSql = this.db.prepare(`
INSERT INTO records (user_id, day, status, shift_type, hours, contraband_found,
contraband_missed, other_penalty, penalty_reason, deleted, rev, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,${NEW_REV},?)
ON CONFLICT(user_id, day) DO UPDATE SET
status=excluded.status, shift_type=excluded.shift_type, hours=excluded.hours,
contraband_found=excluded.contraband_found, contraband_missed=excluded.contraband_missed,
other_penalty=excluded.other_penalty, penalty_reason=excluded.penalty_reason,
deleted=excluded.deleted, rev=excluded.rev, updated_at=excluded.updated_at
`);
for (const r of records) {
stmts.push(recSql.bind(
userId, r.day,
r.deleted ? null : r.status,
r.deleted ? null : (r.shift_type ?? null),
r.deleted ? 0 : (r.hours ?? 0),
r.deleted ? 0 : (r.contraband_found ?? 0),
r.deleted ? 0 : (r.contraband_missed ?? 0),
r.deleted ? null : (r.other_penalty ?? null),
r.deleted ? '' : (r.penalty_reason ?? ''),
r.deleted ? 1 : 0,
userId, now
));
}
const adjSql = this.db.prepare(`
INSERT INTO month_adjust (user_id, month, status, final_amount, note, finalized_at, deleted, rev, updated_at)
VALUES (?,?,?,?,?,?,?,${NEW_REV},?)
ON CONFLICT(user_id, month) DO UPDATE SET
status=excluded.status, final_amount=excluded.final_amount, note=excluded.note,
finalized_at=excluded.finalized_at, deleted=excluded.deleted,
rev=excluded.rev, updated_at=excluded.updated_at
`);
for (const a of adjust) {
stmts.push(adjSql.bind(
userId, a.month,
a.deleted ? 'draft' : a.status,
a.deleted ? null : (a.final_amount ?? null),
a.deleted ? '' : (a.note ?? ''),
a.deleted ? '' : (a.finalized_at ?? ''),
a.deleted ? 1 : 0,
userId, now
));
}
if (settings) {
const cur = await this.db.prepare('SELECT json FROM settings WHERE user_id = ?').bind(userId).first();
let merged = settings;
if (cur) { try { merged = { ...JSON.parse(cur.json), ...settings }; } catch { merged = settings; } }
stmts.push(this.db.prepare(`
INSERT INTO settings (user_id, json, rev, updated_at) VALUES (?,?,${NEW_REV},?)
ON CONFLICT(user_id) DO UPDATE SET json=excluded.json, rev=excluded.rev, updated_at=excluded.updated_at
`).bind(userId, JSON.stringify(merged), userId, now));
}
await this.db.batch(stmts);
return this.getRev(userId);
}
async changesSince(userId, since) {
const [recs, adjs, st] = await Promise.all([
this.db.prepare('SELECT * FROM records WHERE user_id = ? AND rev > ?').bind(userId, since).all(),
this.db.prepare('SELECT * FROM month_adjust WHERE user_id = ? AND rev > ?').bind(userId, since).all(),
this.db.prepare('SELECT * FROM settings WHERE user_id = ? AND rev > ?').bind(userId, since).first()
]);
const records = {}, adjust = {};
for (const r of (recs.results || [])) records[r.day] = r;
for (const a of (adjs.results || [])) adjust[a.month] = a;
return { rev: await this.getRev(userId), records, adjust, settings: st ? JSON.parse(st.json) : null };
}
async fullDump(userId) {
const [recs, adjs, st] = await Promise.all([
this.db.prepare('SELECT * FROM records WHERE user_id = ? AND deleted = 0').bind(userId).all(),
this.db.prepare('SELECT * FROM month_adjust WHERE user_id = ? AND deleted = 0').bind(userId).all(),
this.db.prepare('SELECT * FROM settings WHERE user_id = ?').bind(userId).first()
]);
const records = {}, adjust = {};
for (const r of (recs.results || [])) records[r.day] = r;
for (const a of (adjs.results || [])) adjust[a.month] = a;
return { rev: await this.getRev(userId), records, adjust, settings: st ? JSON.parse(st.json) : null };
}
}
+177
View File
@@ -0,0 +1,177 @@
// 存储适配器:VPS 本机 —— node:sqlite(Node 22 内置,零 npm 依赖)
//
// 与 D1 适配器保持完全相同的行为,关键是 rev 的写法:
// 用 `(SELECT rev FROM users WHERE id=?)` 直接在 SQL 里取新版本号,
// 而不是先 SELECT 回 JS 再拼进 INSERT。这样两个平台都能在单个事务/批次里原子完成。
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
export class SqliteStore {
constructor(path) {
this.db = new DatabaseSync(path);
this.db.exec('PRAGMA journal_mode = WAL'); // 并发读不阻塞写
this.db.exec('PRAGMA foreign_keys = ON');
this.db.exec('PRAGMA busy_timeout = 5000');
this.db.exec(readFileSync(join(__dirname, '..', 'core', 'schema.sql'), 'utf8'));
}
close() { try { this.db.close(); } catch {} }
// ---------- 用户与会话 ----------
async countUsers() {
return this.db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
}
async getUserByUsername(username) {
return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) || null;
}
async getUserById(id) {
return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) || null;
}
async createUser({ username, pwHash, pwSalt, pwIter, createdAt }) {
this.db.prepare(
'INSERT INTO users (username, pw_hash, pw_salt, pw_iter, rev, created_at) VALUES (?,?,?,?,0,?)'
).run(username, pwHash, pwSalt, pwIter, createdAt);
return this.getUserByUsername(username);
}
async createSession({ tokenHash, userId, createdAt, expiresAt }) {
this.db.prepare(
'INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?,?,?,?)'
).run(tokenHash, userId, createdAt, expiresAt);
}
async getSessionWithUser(tokenHash) {
const s = this.db.prepare('SELECT * FROM sessions WHERE token_hash = ?').get(tokenHash);
if (!s) return null;
const u = this.db.prepare('SELECT * FROM users WHERE id = ?').get(s.user_id);
if (!u) return null;
return { session: s, user: u };
}
async deleteSession(tokenHash) {
this.db.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash);
}
async purgeExpiredSessions(nowIso) {
return this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(nowIso).changes;
}
// ---------- 同步 ----------
async getRev(userId) {
const r = this.db.prepare('SELECT rev FROM users WHERE id = ?').get(userId);
return r ? r.rev : 0;
}
async applyPush(userId, { records = [], adjust = [], settings = null }) {
const now = new Date().toISOString();
const NEW_REV = '(SELECT rev FROM users WHERE id = ?)';
this.db.exec('BEGIN IMMEDIATE');
try {
this.db.prepare('UPDATE users SET rev = rev + 1 WHERE id = ?').run(userId);
const upRec = this.db.prepare(`
INSERT INTO records (user_id, day, status, shift_type, hours, contraband_found,
contraband_missed, other_penalty, penalty_reason, deleted, rev, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,${NEW_REV},?)
ON CONFLICT(user_id, day) DO UPDATE SET
status=excluded.status, shift_type=excluded.shift_type, hours=excluded.hours,
contraband_found=excluded.contraband_found, contraband_missed=excluded.contraband_missed,
other_penalty=excluded.other_penalty, penalty_reason=excluded.penalty_reason,
deleted=excluded.deleted, rev=excluded.rev, updated_at=excluded.updated_at
`);
for (const r of records) {
upRec.run(
userId, r.day,
r.deleted ? null : r.status,
r.deleted ? null : (r.shift_type ?? null),
r.deleted ? 0 : (r.hours ?? 0),
r.deleted ? 0 : (r.contraband_found ?? 0),
r.deleted ? 0 : (r.contraband_missed ?? 0),
r.deleted ? null : (r.other_penalty ?? null),
r.deleted ? '' : (r.penalty_reason ?? ''),
r.deleted ? 1 : 0,
userId, now
);
}
const upAdj = this.db.prepare(`
INSERT INTO month_adjust (user_id, month, status, final_amount, note, finalized_at, deleted, rev, updated_at)
VALUES (?,?,?,?,?,?,?,${NEW_REV},?)
ON CONFLICT(user_id, month) DO UPDATE SET
status=excluded.status, final_amount=excluded.final_amount, note=excluded.note,
finalized_at=excluded.finalized_at, deleted=excluded.deleted,
rev=excluded.rev, updated_at=excluded.updated_at
`);
for (const a of adjust) {
upAdj.run(
userId, a.month,
a.deleted ? 'draft' : a.status,
a.deleted ? null : (a.final_amount ?? null),
a.deleted ? '' : (a.note ?? ''),
a.deleted ? '' : (a.finalized_at ?? ''),
a.deleted ? 1 : 0,
userId, now
);
}
if (settings) {
// 设置是整份合并:客户端可能只传改动的字段,不能把没传的清掉
const cur = this.db.prepare('SELECT json FROM settings WHERE user_id = ?').get(userId);
let merged = settings;
if (cur) {
try { merged = { ...JSON.parse(cur.json), ...settings }; } catch { merged = settings; }
}
this.db.prepare(`
INSERT INTO settings (user_id, json, rev, updated_at) VALUES (?,?,${NEW_REV},?)
ON CONFLICT(user_id) DO UPDATE SET json=excluded.json, rev=excluded.rev, updated_at=excluded.updated_at
`).run(userId, JSON.stringify(merged), userId, now);
}
const rev = this.db.prepare('SELECT rev FROM users WHERE id = ?').get(userId).rev;
this.db.exec('COMMIT');
return rev;
} catch (e) {
try { this.db.exec('ROLLBACK'); } catch {}
throw e;
}
}
async changesSince(userId, since) {
const recs = this.db.prepare('SELECT * FROM records WHERE user_id = ? AND rev > ?').all(userId, since);
const adjs = this.db.prepare('SELECT * FROM month_adjust WHERE user_id = ? AND rev > ?').all(userId, since);
const st = this.db.prepare('SELECT * FROM settings WHERE user_id = ? AND rev > ?').get(userId, since);
const records = {}, adjust = {};
for (const r of recs) records[r.day] = r;
for (const a of adjs) adjust[a.month] = a;
return {
rev: await this.getRev(userId),
records,
adjust,
settings: st ? JSON.parse(st.json) : null
};
}
async fullDump(userId) {
const recs = this.db.prepare('SELECT * FROM records WHERE user_id = ? AND deleted = 0').all(userId);
const adjs = this.db.prepare('SELECT * FROM month_adjust WHERE user_id = ? AND deleted = 0').all(userId);
const st = this.db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId);
const records = {}, adjust = {};
for (const r of recs) records[r.day] = r;
for (const a of adjs) adjust[a.month] = a;
return {
rev: await this.getRev(userId),
records,
adjust,
settings: st ? JSON.parse(st.json) : null
};
}
}
+243
View File
@@ -0,0 +1,243 @@
// 平台无关的 API 核心。
// 输入标准 Request,输出标准 Response,不碰任何 Node 或 Workers 专属 API。
// VPS(node-server.js)与 Cloudflare Workers(worker.js)都只是把请求转进来。
import {
hashPassword, verifyPassword, newToken, hashToken, bearerFrom, SESSION_TTL_MS
} from './auth.js';
import {
BadRequest, normalizeRecord, normalizeAdjust, normalizeSettings,
recordRowToClient, adjustRowToClient, DAY_RE, MONTH_RE
} from './store.js';
export const API_VERSION = 1;
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' };
function json(data, status = 200, extra = {}) {
return new Response(JSON.stringify(data), { status, headers: { ...JSON_HEADERS, ...extra } });
}
function err(message, status = 400, code) {
return json({ ok: false, error: message, code: code || undefined }, status);
}
async function readJson(request, maxBytes = 4 * 1024 * 1024) {
const len = Number(request.headers.get('content-length') || 0);
if (len > maxBytes) throw new BadRequest('请求体过大');
let text;
try { text = await request.text(); } catch { throw new BadRequest('请求体读取失败'); }
if (text.length > maxBytes) throw new BadRequest('请求体过大');
if (!text) return {};
try { return JSON.parse(text); } catch { throw new BadRequest('请求体不是合法 JSON'); }
}
/** 简易滑窗限流:登录/注册专用,防在线爆破。内存态,Workers 冷启动会重置,够用。 */
class RateLimiter {
constructor(limit = 10, windowMs = 60000) {
this.limit = limit; this.windowMs = windowMs; this.hits = new Map();
}
check(key) {
const now = Date.now();
const arr = (this.hits.get(key) || []).filter(t => now - t < this.windowMs);
if (arr.length >= this.limit) return false;
arr.push(now);
this.hits.set(key, arr);
if (this.hits.size > 5000) this.hits.clear(); // 兜底,别把内存吃了
return true;
}
}
const loginLimiter = new RateLimiter(10, 60000);
function clientKey(request) {
return request.headers.get('cf-connecting-ip')
|| request.headers.get('x-forwarded-for')
|| request.headers.get('x-real-ip')
|| 'local';
}
async function requireAuth(store, request) {
const token = bearerFrom(request);
if (!token) throw Object.assign(new Error('未登录'), { status: 401 });
const hit = await store.getSessionWithUser(await hashToken(token));
if (!hit) throw Object.assign(new Error('登录已失效,请重新登录'), { status: 401 });
if (new Date(hit.session.expires_at).getTime() < Date.now()) {
await store.deleteSession(hit.session.token_hash);
throw Object.assign(new Error('登录已过期,请重新登录'), { status: 401 });
}
return hit.user;
}
function validUsername(u) {
return typeof u === 'string' && /^[A-Za-z0-9_.-]{3,32}$/.test(u);
}
/**
* 主入口。
* @param {Request} request
* @param {object} store 适配器实例(见 core/store.js 顶部契约)
* @param {object} opts { allowRegister } allowRegister=false 时只允许首个用户注册
*/
export async function handleApi(request, store, opts = {}) {
const url = new URL(request.url);
const path = url.pathname.replace(/\/+$/, '') || '/';
const method = request.method.toUpperCase();
if (method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'access-control-allow-origin': request.headers.get('origin') || '*',
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
'access-control-allow-headers': 'content-type,authorization',
'access-control-max-age': '86400'
}
});
}
try {
// ---------- 健康检查 ----------
if (path === '/api/health' && method === 'GET') {
return json({ ok: true, api: API_VERSION, users: await store.countUsers(), now: new Date().toISOString() });
}
// ---------- 注册 ----------
// 默认策略:只有库里还没有任何用户时才允许注册(首用户即管理员)。
// 这样公网暴露也不会被陌生人开号。要开放注册就显式传 allowRegister:true。
if (path === '/api/register' && method === 'POST') {
if (!loginLimiter.check('reg:' + clientKey(request))) return err('操作过于频繁,请稍后再试', 429);
const body = await readJson(request);
const username = String(body.username || '').trim();
const password = String(body.password || '');
if (!validUsername(username)) return err('用户名需 3–32 位,仅限字母数字与 _ . -');
if (password.length < 8) return err('密码至少 8 位');
const existing = await store.countUsers();
if (existing > 0 && !opts.allowRegister) return err('注册已关闭', 403, 'REGISTER_CLOSED');
if (await store.getUserByUsername(username)) return err('用户名已被占用', 409);
const { pwHash, pwSalt, pwIter } = await hashPassword(password);
const user = await store.createUser({ username, pwHash, pwSalt, pwIter, createdAt: new Date().toISOString() });
return json({ ok: true, user: { id: user.id, username: user.username } }, 201);
}
// ---------- 登录 ----------
if (path === '/api/login' && method === 'POST') {
if (!loginLimiter.check('login:' + clientKey(request))) return err('尝试过于频繁,请稍后再试', 429);
const body = await readJson(request);
const username = String(body.username || '').trim();
const password = String(body.password || '');
const user = await store.getUserByUsername(username);
// 用户不存在也要走一次真实的密码校验开销,否则能靠响应时间枚举用户名
const okPw = user
? await verifyPassword(password, user)
: await verifyPassword(password, { pw_salt: '00'.repeat(16), pw_iter: 100000, pw_hash: '' });
if (!user || !okPw) return err('用户名或密码错误', 401);
const token = newToken();
const now = new Date();
await store.createSession({
tokenHash: await hashToken(token),
userId: user.id,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + SESSION_TTL_MS).toISOString()
});
await store.purgeExpiredSessions(now.toISOString());
return json({ ok: true, token, user: { id: user.id, username: user.username }, rev: user.rev });
}
// ---------- 以下均需登录 ----------
if (path === '/api/logout' && method === 'POST') {
const token = bearerFrom(request);
if (token) await store.deleteSession(await hashToken(token));
return json({ ok: true });
}
if (path === '/api/me' && method === 'GET') {
const user = await requireAuth(store, request);
return json({ ok: true, user: { id: user.id, username: user.username }, rev: await store.getRev(user.id) });
}
// 全量拉取:首次登录、或本地想直接覆盖时用
if (path === '/api/data' && method === 'GET') {
const user = await requireAuth(store, request);
const dump = await store.fullDump(user.id);
return json({ ok: true, ...dump });
}
// 增量拉取:?since=<rev>,只回比它新的变更(含删除墓碑)
if (path === '/api/changes' && method === 'GET') {
const user = await requireAuth(store, request);
const since = Number(url.searchParams.get('since') || 0);
if (!Number.isFinite(since) || since < 0) return err('since 必须是非负整数');
const out = await store.changesSince(user.id, since);
return json({ ok: true, since, ...out });
}
// 推送本地变更。整批原子写入,返回新 rev。
if (path === '/api/push' && method === 'POST') {
const user = await requireAuth(store, request);
const body = await readJson(request);
const records = [];
if (body.records && typeof body.records === 'object') {
for (const [day, raw] of Object.entries(body.records)) records.push(normalizeRecord(day, raw));
}
const adjust = [];
if (body.monthAdjust && typeof body.monthAdjust === 'object') {
for (const [month, raw] of Object.entries(body.monthAdjust)) adjust.push(normalizeAdjust(month, raw));
}
const settings = body.settings !== undefined && body.settings !== null
? normalizeSettings(body.settings)
: null;
if (!records.length && !adjust.length && !settings) {
return json({ ok: true, rev: await store.getRev(user.id), applied: 0 });
}
const rev = await store.applyPush(user.id, { records, adjust, settings });
return json({ ok: true, rev, applied: records.length + adjust.length + (settings ? 1 : 0) });
}
// 删除单日 / 单月:等价于 push 一个墓碑,给客户端一个更直白的入口
if (method === 'DELETE' && path.startsWith('/api/record/')) {
const user = await requireAuth(store, request);
const day = path.slice('/api/record/'.length);
if (!DAY_RE.test(day)) return err('日期格式错误');
const rev = await store.applyPush(user.id, { records: [{ day, deleted: 1 }], adjust: [], settings: null });
return json({ ok: true, rev });
}
if (method === 'DELETE' && path.startsWith('/api/adjust/')) {
const user = await requireAuth(store, request);
const month = path.slice('/api/adjust/'.length);
if (!MONTH_RE.test(month)) return err('月份格式错误');
const rev = await store.applyPush(user.id, { records: [], adjust: [{ month, deleted: 1 }], settings: null });
return json({ ok: true, rev });
}
// 服务端导出:与前端「导出备份」同一份 bundle 结构,可直接互相导入
if (path === '/api/export' && method === 'GET') {
const user = await requireAuth(store, request);
const dump = await store.fullDump(user.id);
const recs = {};
for (const [k, v] of Object.entries(dump.records)) recs[k] = recordRowToClient(v);
const adj = {};
for (const [k, v] of Object.entries(dump.adjust)) adj[k] = adjustRowToClient(v);
return json({
app: 'work-hours-tracker',
version: 3,
exportedAt: new Date().toISOString(),
rev: dump.rev,
settings: dump.settings || {},
records: recs,
monthAdjust: adj
}, 200, { 'content-disposition': 'attachment; filename="work-hours-backup.json"' });
}
return err('接口不存在: ' + path, 404);
} catch (e) {
const status = e.status || (e instanceof BadRequest ? 400 : 500);
if (status >= 500) console.error('[api]', e);
return err(e.message || '服务器内部错误', status);
}
}
+65
View File
@@ -0,0 +1,65 @@
// 认证:只用 Web Crypto(globalThis.crypto.subtle)
// 这样同一份代码在 Node 22 与 Cloudflare Workers 上都能跑,不引入任何 npm 依赖。
const enc = new TextEncoder();
export const DEFAULT_PW_ITER = 100000;
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 天
function toHex(buf) {
const b = new Uint8Array(buf);
let s = '';
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, '0');
return s;
}
function fromHex(hex) {
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
return out;
}
/** PBKDF2-SHA256 派生 32 字节,返回 hex */
export async function derive(password, saltHex, iterations) {
const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: fromHex(saltHex), iterations, hash: 'SHA-256' },
key,
256
);
return toHex(bits);
}
export async function hashPassword(password, iterations = DEFAULT_PW_ITER) {
const salt = toHex(crypto.getRandomValues(new Uint8Array(16)));
const hash = await derive(password, salt, iterations);
return { pwHash: hash, pwSalt: salt, pwIter: iterations };
}
/** 定长比较,避免按字符提前返回造成的时序泄漏 */
export function safeEqualHex(a, b) {
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export async function verifyPassword(password, user) {
const hash = await derive(password, user.pw_salt, user.pw_iter);
return safeEqualHex(hash, user.pw_hash);
}
/** 会话令牌:明文只回给客户端一次,库里只存 SHA-256,泄库也换不出可用 token */
export function newToken() {
return toHex(crypto.getRandomValues(new Uint8Array(32)));
}
export async function hashToken(token) {
return toHex(await crypto.subtle.digest('SHA-256', enc.encode(token)));
}
export function bearerFrom(request) {
const h = request.headers.get('authorization') || '';
const m = /^Bearer\s+([A-Za-z0-9._-]+)$/.exec(h.trim());
return m ? m[1] : null;
}
+67
View File
@@ -0,0 +1,67 @@
-- 工时记账服务端 schema
-- 同时兼容 VPS 上的 node:sqlite 与 Cloudflare D1(都是 SQLite 方言)
-- 设计要点:
-- 1. users.rev 是该用户的单调递增版本号,任何写入都 +1,客户端靠它做增量拉取
-- 2. records / month_adjust 用 deleted 墓碑标记而非物理删除,否则增量同步无法传播「删除」
-- 3. 所有时间戳统一存 ISO 8601 字符串(UTC),避免 D1 与 SQLite 的时间函数差异
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
pw_hash TEXT NOT NULL,
pw_salt TEXT NOT NULL,
pw_iter INTEGER NOT NULL,
rev INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at);
-- 每日打卡:主键 (user_id, day),day 形如 2026-09-08
CREATE TABLE IF NOT EXISTS records (
user_id INTEGER NOT NULL,
day TEXT NOT NULL,
status TEXT,
shift_type TEXT,
hours REAL,
contraband_found INTEGER NOT NULL DEFAULT 0,
contraband_missed INTEGER NOT NULL DEFAULT 0,
other_penalty REAL,
penalty_reason TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
rev INTEGER NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, day)
);
CREATE INDEX IF NOT EXISTS idx_records_rev ON records(user_id, rev);
-- 月度奖惩终稿:主键 (user_id, month),month 形如 2026-09
-- status = draft(草稿,不计入金额) | final(组长审核定稿,计入金额)
CREATE TABLE IF NOT EXISTS month_adjust (
user_id INTEGER NOT NULL,
month TEXT NOT NULL,
status TEXT NOT NULL,
final_amount REAL,
note TEXT,
finalized_at TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
rev INTEGER NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, month)
);
CREATE INDEX IF NOT EXISTS idx_adjust_rev ON month_adjust(user_id, rev);
-- 设置整体存一份 JSON:字段会随功能迭代增减,拆列反而不好演进
CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER PRIMARY KEY,
json TEXT NOT NULL,
rev INTEGER NOT NULL,
updated_at TEXT NOT NULL
);
+191
View File
@@ -0,0 +1,191 @@
// 存储层契约(Store Contract)
//
// 为什么要有这一层:现在跑 VPS(node:sqlite),以后要上 Cloudflare Workers(D1),
// 再往后做 APP 还可能换别的后端。把「业务逻辑」和「怎么存」切开,
// 换平台只需要重写一个适配器,core/api.js 一行都不用动。
//
// 适配器必须实现下列方法。所有时间统一 ISO 8601 UTC 字符串。
//
// // ---- 用户与会话 ----
// countUsers(): Promise<number>
// getUserByUsername(username): Promise<UserRow|null>
// getUserById(id): Promise<UserRow|null>
// createUser({ username, pwHash, pwSalt, pwIter, createdAt }): Promise<UserRow>
// createSession({ tokenHash, userId, createdAt, expiresAt }): Promise<void>
// getSessionWithUser(tokenHash): Promise<{ session, user }|null>
// deleteSession(tokenHash): Promise<void>
// purgeExpiredSessions(nowIso): Promise<number>
//
// // ---- 数据同步 ----
// getRev(userId): Promise<number>
// // 原子写入:内部自行 +1 rev、给所有行打上同一个 rev,返回新 rev
// applyPush(userId, { records, adjust, settings }): Promise<number>
// // 增量拉取:返回 rev > since 的全部变更(含 deleted 墓碑)
// changesSince(userId, since): Promise<{ rev, records, adjust, settings }>
// // 全量导出,用于备份与首次同步
// fullDump(userId): Promise<{ rev, records, adjust, settings }>
//
// UserRow: { id, username, pw_hash, pw_salt, pw_iter, rev, created_at }
export const HOUR_MIN = 6;
export const HOUR_MAX = 12;
const STATUS = new Set(['work', 'rest', 'leave']);
const SHIFT = new Set(['day', 'night']);
const ADJUST_STATUS = new Set(['draft', 'final']);
export const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
export const MONTH_RE = /^\d{4}-\d{2}$/;
/**
* 键的合法性不能只看形状。`2026-13` 能过 /^\d{4}-\d{2}$/,`2026-02-31` 也能过日期正则,
* 但它们都不是真实存在的月份/日期,放进库里会变成永远算不进任何月份的孤儿行。
*/
export function isValidMonthKey(k) {
if (!MONTH_RE.test(k)) return false;
const mo = Number(k.slice(5, 7));
return mo >= 1 && mo <= 12;
}
export function isValidDayKey(k) {
if (!DAY_RE.test(k)) return false;
const y = Number(k.slice(0, 4)), mo = Number(k.slice(5, 7)), d = Number(k.slice(8, 10));
if (mo < 1 || mo > 12 || d < 1 || d > 31) return false;
// 用 UTC 构造再回读,闰年与月末天数一次性交给 Date 判定
const dt = new Date(Date.UTC(y, mo - 1, d));
return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
}
/** 设置字段白名单:与前端 DEFAULTS 对齐,多余字段一律丢弃 */
export const SETTINGS_FIELDS = {
rate: 'number',
fullDays: 'number',
fullBonusPerHour: 'number',
foundBonus: 'number',
missedPenalty: 'number',
cutoff: 'number',
payDayA: 'number',
payDayB: 'number',
dayHours: 'number',
nightHours: 'number',
defaultShift: 'shift'
};
export class BadRequest extends Error {
constructor(msg) { super(msg); this.status = 400; }
}
function num(v, name, { min = -1e9, max = 1e9, int = false } = {}) {
const n = Number(v);
if (!Number.isFinite(n)) throw new BadRequest(`${name} 必须是数字`);
if (int && !Number.isInteger(n)) throw new BadRequest(`${name} 必须是整数`);
if (n < min || n > max) throw new BadRequest(`${name} 超出范围 [${min}, ${max}]`);
return n;
}
/**
* 规范化单日打卡。
* 服务端不做「业务合理性」判断(比如工时必须 6–12),只做类型与边界校验,
* 因为业务规则会变,而历史数据不该因为改规则就存不进来。
*/
export function normalizeRecord(day, raw) {
if (!isValidDayKey(day)) throw new BadRequest(`日期键不是有效日期: ${day}`);
if (raw === null || raw === undefined) return { day, deleted: 1 };
if (typeof raw !== 'object') throw new BadRequest(`${day} 的记录必须是对象`);
if (raw.deleted === true || raw.deleted === 1) return { day, deleted: 1 };
const status = String(raw.status || '');
if (!STATUS.has(status)) throw new BadRequest(`${day} 的 status 只能是 work/rest/leave`);
if (status !== 'work') {
// 休息/请假不携带工时与奖惩,统一清零,避免脏数据参与结算
return {
day, deleted: 0, status,
shift_type: null, hours: 0,
contraband_found: 0, contraband_missed: 0,
other_penalty: null, penalty_reason: ''
};
}
const shift = String(raw.shift_type || '');
if (!SHIFT.has(shift)) throw new BadRequest(`${day} 的 shift_type 只能是 day/night`);
const op = raw.other_penalty;
const penalty = (op === null || op === undefined || op === '')
? null
: num(op, `${day} 的 other_penalty`, { min: 0, max: 100000 });
return {
day, deleted: 0, status,
shift_type: shift,
hours: num(raw.hours, `${day} 的 hours`, { min: 0, max: 24 }),
contraband_found: num(raw.contraband_found ?? 0, `${day} 的 contraband_found`, { min: 0, max: 9999, int: true }),
contraband_missed: num(raw.contraband_missed ?? 0, `${day} 的 contraband_missed`, { min: 0, max: 9999, int: true }),
other_penalty: penalty,
penalty_reason: String(raw.penalty_reason || '').slice(0, 200)
};
}
/** 规范化月度奖惩终稿 */
export function normalizeAdjust(month, raw) {
if (!isValidMonthKey(month)) throw new BadRequest(`月份键不是有效月份: ${month}`);
if (raw === null || raw === undefined) return { month, deleted: 1 };
if (typeof raw !== 'object') throw new BadRequest(`${month} 的奖惩必须是对象`);
if (raw.deleted === true || raw.deleted === 1) return { month, deleted: 1 };
const status = String(raw.status || '');
if (!ADJUST_STATUS.has(status)) throw new BadRequest(`${month} 的 status 只能是 draft/final`);
const amt = raw.finalAmount ?? raw.final_amount;
// draft 允许金额为空(还没填完就存草稿);final 必须有确定金额,否则前端无法判定「已定稿」
let finalAmount = null;
if (amt !== null && amt !== undefined && amt !== '') {
finalAmount = num(amt, `${month} 的 finalAmount`, { min: -1000000, max: 1000000 });
} else if (status === 'final') {
throw new BadRequest(`${month} 定稿必须填写终稿金额`);
}
return {
month, deleted: 0, status, final_amount: finalAmount,
note: String(raw.note || '').slice(0, 200),
finalized_at: status === 'final' ? String(raw.finalizedAt || raw.finalized_at || new Date().toISOString()) : ''
};
}
/** 规范化设置:只保留白名单字段 */
export function normalizeSettings(raw) {
if (!raw || typeof raw !== 'object') throw new BadRequest('settings 必须是对象');
const out = {};
for (const [k, kind] of Object.entries(SETTINGS_FIELDS)) {
if (raw[k] === undefined) continue;
if (kind === 'number') out[k] = num(raw[k], `settings.${k}`, { min: 0, max: 100000 });
else if (kind === 'shift') {
const v = String(raw[k]);
if (!SHIFT.has(v)) throw new BadRequest('settings.defaultShift 只能是 day/night');
out[k] = v;
}
}
return out;
}
/** DB 行 → 前端使用的记录对象(去掉同步用的内部字段) */
export function recordRowToClient(r) {
return {
status: r.status,
shift_type: r.shift_type,
hours: r.hours,
contraband_found: r.contraband_found,
contraband_missed: r.contraband_missed,
other_penalty: r.other_penalty,
penalty_reason: r.penalty_reason || ''
};
}
export function adjustRowToClient(r) {
return {
status: r.status,
finalAmount: r.final_amount,
note: r.note || '',
finalizedAt: r.finalized_at || ''
};
}
+397
View File
@@ -0,0 +1,397 @@
// VPS 本机入口:一个进程同时提供静态页面与 /api/*
//
// 为什么不用 express/hono:core/api.js 收发的是标准 Request/Response,
// Node 22 原生就有,加依赖只会让 CF Workers 那边多一层不必要的适配。
//
// 环境变量:
// PORT 监听端口(默认 9523,避开现有的 9522 静态服务)
// HOST 监听地址(默认 0.0.0.0;已有登录鉴权,但仍建议配反代 + HTTPS)
// DB_PATH SQLite 文件(默认 <server>/data/work-hours.db)
// STATIC_DIR 静态目录(默认项目根,即 index.html 所在处)
// ALLOW_REGISTER=1 开放注册(默认只允许「库里还没有用户」时注册首个账号)
// ACCESS_LOG 访问日志文件(默认 <server>/data/access.log;设 off 关闭)
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { mkdirSync, createWriteStream, statSync, renameSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { promisify } from 'node:util';
import { gzip, brotliCompress, constants as zlibConstants } from 'node:zlib';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve, normalize, extname } from 'node:path';
import { SqliteStore } from './adapters/sqlite-node.js';
import { handleApi } from './core/api.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 9523);
const HOST = process.env.HOST || '0.0.0.0';
const DB_PATH = process.env.DB_PATH || join(__dirname, 'data', 'work-hours.db');
const STATIC_DIR = resolve(process.env.STATIC_DIR || join(__dirname, '..'));
const ALLOW_REGISTER = process.env.ALLOW_REGISTER === '1';
const ACCESS_LOG = process.env.ACCESS_LOG === 'off'
? null
: resolve(process.env.ACCESS_LOG || join(__dirname, 'data', 'access.log'));
mkdirSync(dirname(DB_PATH), { recursive: true });
const store = new SqliteStore(DB_PATH);
// ===== 访问日志 =====
// 存在的理由很具体:出现过「页面到了、167KB 的 vue.global.prod.js 没到」这类单请求失败,
// 服务端却完全查不到那次请求,只能靠猜。记下状态码与实发字节数,下次同类问题可直接对账:
// 有记录且 bytes 完整 → 问题在客户端/链路;没记录 → 请求根本没到服务器。
// 只记这一台自用服务需要的最小字段,不记 cookie / Authorization / 请求体。
const LOG_MAX_BYTES = 8 * 1024 * 1024; // 超过就滚动一次,避免无人看管长成巨型文件
let logStream = null;
function openLog() {
if (!ACCESS_LOG) return;
mkdirSync(dirname(ACCESS_LOG), { recursive: true });
try {
if (statSync(ACCESS_LOG).size > LOG_MAX_BYTES) renameSync(ACCESS_LOG, ACCESS_LOG + '.1');
} catch { /* 文件不存在,正常 */ }
logStream = createWriteStream(ACCESS_LOG, { flags: 'a' });
// 日志写不动绝不能拖垮服务,出错就退化成「不记日志」继续跑。
logStream.on('error', e => {
console.error('[access-log] 写入失败,已停用日志:', e.message);
logStream = null;
});
}
openLog();
function clientIp(req) {
const fwd = req.headers['x-forwarded-for'];
if (typeof fwd === 'string' && fwd) return fwd.split(',')[0].trim();
return req.socket.remoteAddress || '-';
}
/**
* 记一条访问。
* status 用 '-' 表示连响应头都没发出去(客户端中途断开时会这样)。
* sent 是本次响应实际写出的字节数(含响应头,所以比 want 大几百字节是正常的),want 是 body 应发字节数。
* sent 明显小于 want 就是传输被截断——这正是 167KB 的 Vue 只到一半那类问题要抓的证据。
* @param {import('node:net').Socket|null} sock 开头就存下的 socket 引用:res.socket 在 close 时已经置空,现场取不到。
* @param {number} baseline 该 socket 在本次请求前已写字节数;keep-alive 下一条连接跑多个请求,不减就会累加。
*/
function writeAccess(req, res, startedAt, sock, baseline) {
if (!logStream) return;
const ms = Date.now() - startedAt;
const status = res.headersSent ? res.statusCode : '-';
const sent = sock ? Math.max(0, sock.bytesWritten - baseline) : 0;
// 不能用 res.getHeader('content-length'):经 writeHead(status, obj) 设的头不进可查询的头集合,
// 读出来永远是 undefined(实测过,最初写成那样日志里全是 want=-)。改由写响应的地方直接标上。
const want = res.wantBytes ?? '-';
const ua = String(req.headers['user-agent'] || '-').replace(/["\n\r]/g, ' ').slice(0, 200);
const line = [
new Date().toISOString(),
clientIp(req),
req.method,
(req.url || '-').slice(0, 300),
status,
`sent=${sent}`,
`want=${want}`,
ms + 'ms',
res.writableFinished ? 'done' : 'aborted',
'"' + ua + '"'
].join(' ');
logStream.write(line + '\n');
}
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json'
};
// 静态目录里混着源码、数据库、备份和调试产物,逐类挡掉。
// vendor/ 是页面真实依赖(tailwind.css / vue.global.prod.js),不能挡。
// shared/ 同样不能挡:payroll.js 是前端要 <script> 引的薪酬模块。
// .build/ 是构建时工具链(带 node_modules),跑时用不到,必须挡。
// test/ 与 tools/ 是测试网与基准生成器,只在开发机跑;basline JSON 里含全部场景明细,
// 发到公网既没用也多一份暴露面(挂上测试层时实测这几个路径都是 200,逐个堵掉)。
// package.json 会把脚本名与内部路径抖出去,一并挡。
// 注意 .db 后面可能跟时间戳(回收站里的文件),所以不能只匹配结尾。
const BLOCKED = /(^|\/)(server|\.trash|\.build|\.icons|design|png|test|tools|node_modules|\.git)(\/|$)|\.bak(\.|$)|\.db(\.|-|$)|\.sqlite|\.sql$|^\/verify_.*\.js$|^\/package(-lock)?\.json$/i;
// ===== 缓存策略 =====
// 起因:静态文件原来一律发 no-cache,于是每次开页面都要在移动网络上重下
// index.html(132KB) + vue.global.prod.js(164KB) + tailwind.css(23KB) ≈ 320KB。
// 访问日志里手机端每次实传约 497ms,本机 0–1ms。那 497ms 就是失败窗口:链路一抖,
// Vue 没到,页面直接掉进 boot-fail 卡片。少传一遍就是少一次失败机会。
//
// 分两档:
// vendor/* 内容不变就永远别再问 → 一年期 immutable。
// 失效靠 URL 上的内容指纹(?v=<hash>),发 index.html 时由服务端注入。
// 不手写版本号:手写迟早会忘,忘了就是用户抱着旧 CSS 看错版式,比慢更难查。
// 文件一变指纹就变,换来的新 URL 天然是一次 cache miss,升级什么都不用改。
// 其余 仍是 no-cache,但带上 ETag。
// no-cache 不等于不缓存,它只要求「用前先问一次」,正好配 ETag:
// 内容没变就回 304 空响应,index.html 这 132KB 由此变成几百字节。
const VENDOR_FILES = ['vendor/tailwind.css', 'vendor/vue.global.prod.js'];
const VENDOR_SET = new Set(VENDOR_FILES.map(p => '/' + p));
const IMMUTABLE = 'public, max-age=31536000, immutable';
// ===== 压缩 =====
// 这是本页面加载失败的真正大头,之前一直没做。
// 访问日志里 vue.global.prod.js 一笔 167KB 走了 488ms(约 340KB/s),整页裸传 346KB
// → 一秒多的传输窗口,链路在这期间抖一下 Vue 就到不了,页面掉进 boot-fail 卡片。
// 实测这几个文件 gzip 后合计约 108KB,只剩三分之一,窗口跟着缩到三分之一。
//
// 日志里那些 "1ms" 不能当成"很快就到了":sent 统计的是写进内核 socket 缓冲区的字节,
// 小文件一次写完就返回,close 立刻触发。只有超出缓冲区的大文件才会阻塞到客户端收完,
// 488ms 才是真实的客户端下载耗时。所以别被小文件的 1ms 骗过去。
const COMPRESSIBLE = /\.(html|js|mjs|css|json|svg|webmanifest)$/i;
const MIN_COMPRESS = 1024; // 比这还小,压缩省的字节抵不上头部与 CPU 开销
const gzipAsync = promisify(gzip);
const brotliAsync = promisify(brotliCompress);
const sha12 = buf => createHash('sha256').update(buf).digest('hex').slice(0, 12);
const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* 只有「带 ?v= 的 vendor 请求」才发长缓存。
* 裸 URL 一律 no-cache:页面里的引用都被打了指纹,不带 v 的地址页面根本不会用,
* 给它一年期缓存是白担风险。
*/
function cacheFor(relPath, search) {
return VENDOR_SET.has(relPath) && /[?&]v=/.test(search || '') ? IMMUTABLE : 'no-cache';
}
// 绝对路径 → { key, buf, fp, variants }。按 mtime+size 失效,压缩结果跟着一起缓存,
// 所以同一个文件只压一次。文件数是个位数,不需要 LRU。
const assets = new Map();
/**
* 读一个静态资源,算内容指纹;index.html 额外把 vendor 引用改写成带指纹的形式。
*
* 正则里可选的 (\?v=…) 是为了幂等:源文件已经带指纹时不会叠成 ?v=a?v=b。
* index.html 里 boot-fail 的 VUE_SRC 常量写的是同一个路径,会被一起替换 ——
* 这正是需要的:重试按钮必须去撞和 <script> 完全相同的缓存条目,否则它「重试成功」
* 之后 reload 用的还是那份坏副本,用户会卡在按钮点不好的死循环里。
*
* 指纹必须按改写后的内容算,不能按源文件的 mtime/size 算:
* 升级 Vue 时 index.html 源文件一个字节都没动,但它该发出的 ?v= 变了。
* 若 ETag 沿用源文件特征,浏览器会拿到 304、继续用旧 ?v=,新版本永远到不了用户手上。
* 所以缓存键把两个 vendor 指纹一起算进去。
* 某个 vendor 读不到就跳过它(fp=null),页面退化成原来的无指纹引用,
* 而不是整页发不出去 —— 少一层缓存优化可以接受,白屏不行。
*/
async function loadAsset(abs, relPath, st0) {
const st = st0 || await stat(abs);
const isIndex = relPath === '/index.html';
let fps = null;
if (isIndex) {
fps = [];
for (const rel of VENDOR_FILES) {
try { fps.push((await loadAsset(join(STATIC_DIR, rel), '/' + rel)).fp); }
catch { fps.push(null); }
}
}
const key = `${st.mtimeMs}:${st.size}` + (fps ? ':' + fps.join(',') : '');
const hit = assets.get(abs);
if (hit && hit.key === key) return hit;
let buf = await readFile(abs);
if (isIndex) {
let html = buf.toString('utf8');
VENDOR_FILES.forEach((rel, i) => {
if (!fps[i]) return;
html = html.replace(new RegExp(escapeRe(rel) + '(\\?v=[0-9a-f]+)?', 'g'), `${rel}?v=${fps[i]}`);
});
buf = Buffer.from(html, 'utf8');
}
const entry = { key, buf, fp: sha12(buf), variants: new Map() };
assets.set(abs, entry);
return entry;
}
/**
* 取某个编码下的响应体。压缩一次就存住,之后同一编码直接复用。
*
* ETag 带上编码后缀:同一份内容的 gzip 与未压缩版本必须是不同的 ETag,
* 否则中间缓存有机会把压缩正文配上未压缩的 ETag 发给不支持压缩的客户端。
*/
async function variantOf(entry, enc) {
if (!enc) return { buf: entry.buf, etag: `"${entry.fp}"` };
const hit = entry.variants.get(enc);
if (hit) return hit;
const buf = enc === 'br'
? await brotliAsync(entry.buf, {
params: {
[zlibConstants.BROTLI_PARAM_QUALITY]: 10,
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: entry.buf.length
}
})
: await gzipAsync(entry.buf, { level: 9 });
const v = { buf, etag: `"${entry.fp}-${enc}"` };
entry.variants.set(enc, v);
return v;
}
/** 按 Accept-Encoding 选编码。只压文本类;显式 q=0 视为不支持。 */
function pickEncoding(req, relPath) {
if (!COMPRESSIBLE.test(relPath)) return null;
const ae = String(req.headers['accept-encoding'] || '');
const ok = name => new RegExp(`(^|,)\\s*${name}\\s*(;\\s*q=(?!0(\\.0*)?(,|$))[^,]*)?(,|$)`, 'i').test(ae);
if (ok('br')) return 'br';
if (ok('gzip')) return 'gzip';
return null;
}
/** 比对 If-None-Match。有中间设备会把强 ETag 降级成 W/"…",所以剥掉前缀再比。 */
function etagMatches(header, etag) {
if (!header) return false;
return header.split(',').some(t => t.trim().replace(/^W\//, '') === etag);
}
/** Node IncomingMessage → 标准 Request */
async function toRequest(req) {
const url = `http://${req.headers.host || 'localhost'}${req.url}`;
const headers = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
if (Array.isArray(v)) v.forEach(x => headers.append(k, x));
else if (v !== undefined) headers.set(k, v);
}
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
let body;
if (hasBody) {
const chunks = [];
let size = 0;
for await (const c of req) {
size += c.length;
if (size > 8 * 1024 * 1024) throw new Error('body too large');
chunks.push(c);
}
body = Buffer.concat(chunks);
}
return new Request(url, { method: req.method, headers, body });
}
/** 标准 Response → Node ServerResponse */
async function sendResponse(res, response) {
const headers = {};
response.headers.forEach((v, k) => { headers[k] = v; });
res.writeHead(response.status, headers);
if (response.body) {
const buf = Buffer.from(await response.arrayBuffer());
res.wantBytes = buf.length;
res.end(buf);
} else res.end();
}
async function serveStatic(req, res, pathname, search) {
// 防目录穿越:normalize 后必须仍在 STATIC_DIR 内
let rel = decodeURIComponent(pathname);
if (rel === '/' || rel === '') rel = '/index.html';
const target = resolve(join(STATIC_DIR, normalize(rel)));
if (!target.startsWith(STATIC_DIR)) {
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
return res.end('403 Forbidden');
}
// 不对外暴露:服务端源码与数据库、历史备份、回收站、验证脚本。
// 这些都不是页面资源,泄出去只有坏处(旧 python http.server 就把它们全发了)。
const relPath = target.slice(STATIC_DIR.length).replace(/\\/g, '/');
if (BLOCKED.test(relPath)) {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
return res.end('404 Not Found');
}
try {
const st = await stat(target);
if (st.isDirectory()) return serveStatic(req, res, join(rel, 'index.html'), search);
const entry = await loadAsset(target, relPath, st);
const enc = entry.buf.length >= MIN_COMPRESS ? pickEncoding(req, relPath) : null;
const { buf, etag } = await variantOf(entry, enc);
const cc = cacheFor(relPath, search);
// Vary 只对可压缩类型有意义;对图片等加上只会白白降低中间缓存命中率
const common = { etag, 'cache-control': cc };
if (COMPRESSIBLE.test(relPath)) common.vary = 'accept-encoding';
if (etagMatches(req.headers['if-none-match'], etag)) {
res.wantBytes = 0; // 日志里 want=0 即代表命中 304,没传正文
res.writeHead(304, common);
return res.end();
}
res.wantBytes = buf.length;
res.writeHead(200, {
'content-type': MIME[extname(target).toLowerCase()] || 'application/octet-stream',
'content-length': buf.length,
...(enc ? { 'content-encoding': enc } : {}),
...common
});
res.end(buf);
} catch {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('404 Not Found');
}
}
const server = createServer(async (req, res) => {
const startedAt = Date.now();
// socket 必须现在存下:close 触发时 res.socket 已置空,到那时才读永远是 0。
const sock = res.socket;
const baseline = sock ? sock.bytesWritten : 0;
// 挂 close 而不是 finish:finish 只在正常写完时触发,
// 而「传到一半客户端断开」恰恰是要留证据的情况,close 两种都能覆盖。
res.on('close', () => writeAccess(req, res, startedAt, sock, baseline));
try {
const u = new URL(req.url, 'http://x');
if (u.pathname.startsWith('/api/')) {
const response = await handleApi(await toRequest(req), store, { allowRegister: ALLOW_REGISTER });
return await sendResponse(res, response);
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
return res.end('405 Method Not Allowed');
}
await serveStatic(req, res, u.pathname, u.search);
} catch (e) {
console.error('[server]', e);
if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: '服务器内部错误' }));
}
});
server.listen(PORT, HOST, async () => {
const n = await store.countUsers();
// 预热:首个请求不该为「第一次压缩」买单。这几个文件是每次开页面都要的。
// 失败不影响启动,真有请求时会再压一次。
const warm = ['/index.html', '/sync.js', ...VENDOR_FILES.map(p => '/' + p)];
let warmed = 0;
for (const rel of warm) {
try {
const entry = await loadAsset(join(STATIC_DIR, rel), rel);
await variantOf(entry, 'br');
await variantOf(entry, 'gzip');
warmed++;
} catch { /* 缺文件就跳过 */ }
}
console.log(`工时记账服务已启动 http://${HOST}:${PORT}`);
console.log(` 数据库 ${DB_PATH}`);
console.log(` 静态目录 ${STATIC_DIR}`);
console.log(` 压缩预热 ${warmed}/${warm.length} 个资源(br + gzip)`);
console.log(` 注册策略 ${ALLOW_REGISTER ? '开放注册' : (n === 0 ? '等待创建首个账号' : '已关闭(库中已有账号)')}`);
console.log(` 现有用户 ${n}`);
console.log(` 访问日志 ${ACCESS_LOG || '已关闭'}`);
});
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
console.log(`\n收到 ${sig},正在关闭…`);
server.close(() => { store.close(); logStream?.end(); process.exit(0); });
});
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "work-hours-tracker-server",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "工时记账服务端:平台无关核心 + 可替换存储适配器(VPS node:sqlite / Cloudflare D1)",
"main": "node-server.js",
"scripts": {
"start": "node --disable-warning=ExperimentalWarning node-server.js",
"test": "node --disable-warning=ExperimentalWarning test/api.test.js",
"schema:dump": "node scripts/dump-schema.js",
"cf:dev": "wrangler dev",
"cf:deploy": "wrangler deploy",
"cf:migrate": "wrangler d1 execute work-hours --remote --file=./core/schema.sql"
},
"engines": {
"node": ">=22.5.0"
}
}
+276
View File
@@ -0,0 +1,276 @@
// 服务端 API 自测:直接调 core/api.js,不起 HTTP 端口,跑得快也不占端口。
// 存储用临时 SQLite 文件,跑完删掉。
//
// node --disable-warning=ExperimentalWarning test/api.test.js
import { handleApi } from '../core/api.js';
import { SqliteStore } from '../adapters/sqlite-node.js';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let pass = 0, fail = 0;
const tmp = mkdtempSync(join(tmpdir(), 'wht-test-'));
const store = new SqliteStore(join(tmp, 'test.db'));
function section(t) { console.log(`\n── ${t}`); }
function ok(name, cond, extra = '') {
if (cond) { pass++; console.log(` ✅ ${name}`); }
else { fail++; console.log(` ❌ ${name}${extra ? ' → ' + extra : ''}`); }
}
function eq(name, actual, expected) {
const a = JSON.stringify(actual), e = JSON.stringify(expected);
ok(name, a === e, `实得 ${a},应为 ${e}`);
}
let TOKEN = null;
async function call(method, path, body, token = TOKEN) {
const headers = { 'content-type': 'application/json' };
if (token) headers.authorization = `Bearer ${token}`;
const req = new Request('http://t' + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body)
});
const res = await handleApi(req, store, {});
let json = null;
try { json = await res.json(); } catch {}
return { status: res.status, body: json };
}
const REC = (h, extra = {}) => ({
status: 'work', shift_type: 'day', hours: h,
contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: '',
...extra
});
// ==========================================================
section('健康检查与注册策略');
{
let r = await call('GET', '/api/health');
eq('health 200', r.status, 200);
eq('初始 0 用户', r.body.users, 0);
r = await call('POST', '/api/register', { username: 'ab', password: 'longenough1' });
eq('用户名过短被拒', r.status, 400);
r = await call('POST', '/api/register', { username: 'kory', password: 'short' });
eq('密码过短被拒', r.status, 400);
r = await call('POST', '/api/register', { username: 'kory', password: 'kory-pass-123' });
eq('首个账号注册成功', r.status, 201);
r = await call('POST', '/api/register', { username: 'kory', password: 'kory-pass-123' });
eq('重名/注册已关闭', r.status, 403); // 已有用户且未开放注册 → 403 先命中
r = await call('GET', '/api/health');
eq('health 显示 1 用户', r.body.users, 1);
}
section('登录与鉴权');
{
let r = await call('POST', '/api/login', { username: 'kory', password: 'wrong-pass-x' }, null);
eq('错密码 401', r.status, 401);
r = await call('POST', '/api/login', { username: 'nobody', password: 'kory-pass-123' }, null);
eq('不存在的用户也是 401(不泄漏用户名)', r.status, 401);
r = await call('POST', '/api/login', { username: 'kory', password: 'kory-pass-123' }, null);
eq('登录成功', r.status, 200);
ok('返回 token', typeof r.body.token === 'string' && r.body.token.length === 64);
eq('初始 rev = 0', r.body.rev, 0);
TOKEN = r.body.token;
r = await call('GET', '/api/data', undefined, null);
eq('无 token 取数据 401', r.status, 401);
r = await call('GET', '/api/data', undefined, 'deadbeef');
eq('伪造 token 401', r.status, 401);
r = await call('GET', '/api/me');
eq('me 200', r.status, 200);
eq('me 用户名', r.body.user.username, 'kory');
}
section('推送与拉取');
{
let r = await call('POST', '/api/push', {
records: { '2026-09-01': REC(8), '2026-09-02': REC(8), '2026-09-03': REC(6, { contraband_found: 1 }) },
settings: { rate: 19.5, fullDays: 27 }
});
eq('push 200', r.status, 200);
eq('rev 递增到 1', r.body.rev, 1);
eq('applied = 4', r.body.applied, 4);
r = await call('GET', '/api/data');
eq('拉到 3 天', Object.keys(r.body.records).length, 3);
eq('9/3 工时 6h', r.body.records['2026-09-03'].hours, 6);
eq('9/3 查获 1 件', r.body.records['2026-09-03'].contraband_found, 1);
eq('设置 rate', r.body.settings.rate, 19.5);
// 增量:since=1 应该什么都没有
r = await call('GET', '/api/changes?since=1');
eq('since=1 无变更', Object.keys(r.body.records).length, 0);
eq('since=1 设置无变更', r.body.settings, null);
// 再改一天
r = await call('POST', '/api/push', { records: { '2026-09-04': REC(10, { contraband_missed: 2 }) } });
eq('rev 递增到 2', r.body.rev, 2);
r = await call('GET', '/api/changes?since=1');
eq('since=1 拿到 1 条新增', Object.keys(r.body.records).length, 1);
eq('新增的是 9/4', Object.keys(r.body.records)[0], '2026-09-04');
eq('changes 里 rev = 2', r.body.rev, 2);
}
section('设置是合并而非覆盖');
{
let r = await call('POST', '/api/push', { settings: { rate: 21 } });
eq('只推 rate', r.status, 200);
r = await call('GET', '/api/data');
eq('rate 已更新', r.body.settings.rate, 21);
eq('fullDays 未被清掉', r.body.settings.fullDays, 27);
}
section('删除用墓碑传播');
{
let r = await call('DELETE', '/api/record/2026-09-04');
eq('删除 200', r.status, 200);
const revAfterDelete = r.body.rev;
r = await call('GET', '/api/data');
ok('fullDump 里已看不到 9/4', r.body.records['2026-09-04'] === undefined);
eq('fullDump 剩 3 天', Object.keys(r.body.records).length, 3);
r = await call('GET', `/api/changes?since=${revAfterDelete - 1}`);
eq('增量里能看到 9/4 墓碑', r.body.records['2026-09-04'].deleted, 1);
r = await call('DELETE', '/api/record/2026-9-4');
eq('非法日期格式被拒', r.status, 400);
}
section('月度奖惩:draft 不需要金额,final 必须有');
{
let r = await call('POST', '/api/push', { monthAdjust: { '2026-09': { status: 'draft', note: '还没算完' } } });
eq('draft 无金额可存', r.status, 200);
r = await call('POST', '/api/push', { monthAdjust: { '2026-09': { status: 'final', note: '组长核定' } } });
eq('final 缺金额被拒', r.status, 400);
ok('错误信息点明原因', /终稿金额/.test(r.body.error), r.body.error);
r = await call('POST', '/api/push', {
monthAdjust: { '2026-09': { status: 'final', finalAmount: -55, note: '扣漏查', finalizedAt: '2026-10-01T09:00:00.000Z' } }
});
eq('final 带金额成功', r.status, 200);
r = await call('GET', '/api/data');
eq('终稿状态', r.body.adjust['2026-09'].status, 'final');
eq('终稿金额支持负数', r.body.adjust['2026-09'].final_amount, -55);
eq('定稿时间原样保留', r.body.adjust['2026-09'].finalized_at, '2026-10-01T09:00:00.000Z');
r = await call('POST', '/api/push', { monthAdjust: { '2026-13': { status: 'draft' } } });
eq('非法月份被拒', r.status, 400);
}
section('输入校验');
{
const bad = [
['status 非法', { records: { '2026-09-05': { status: 'sleep' } } }],
['shift 非法', { records: { '2026-09-05': { status: 'work', shift_type: 'noon', hours: 8 } } }],
['hours 非数字', { records: { '2026-09-05': REC('abc') } }],
['hours 超界', { records: { '2026-09-05': REC(99) } }],
['查获件数为小数', { records: { '2026-09-05': REC(8, { contraband_found: 1.5 }) } }],
['日期键格式错', { records: { '20260905': REC(8) } }],
['defaultShift 非法', { settings: { defaultShift: 'noon' } }]
];
for (const [name, body] of bad) {
const r = await call('POST', '/api/push', body);
ok(name + ' → 400', r.status === 400, `实得 ${r.status} ${r.body && r.body.error}`);
}
// 休息日必须把工时与奖惩清零,防脏数据参与结算
let r = await call('POST', '/api/push', {
records: { '2026-09-06': { status: 'rest', shift_type: 'day', hours: 8, contraband_found: 3 } }
});
eq('休息日可存', r.status, 200);
r = await call('GET', '/api/data');
eq('休息日工时归零', r.body.records['2026-09-06'].hours, 0);
eq('休息日查获归零', r.body.records['2026-09-06'].contraband_found, 0);
eq('休息日班次清空', r.body.records['2026-09-06'].shift_type, null);
}
section('导出格式与前端 bundle 对齐');
{
const r = await call('GET', '/api/export');
eq('export 200', r.status, 200);
eq('app 标识', r.body.app, 'work-hours-tracker');
eq('version 3', r.body.version, 3);
ok('records 是前端结构(有 penalty_reason 无 rev)',
r.body.records['2026-09-01'].penalty_reason !== undefined && r.body.records['2026-09-01'].rev === undefined);
ok('monthAdjust 用 finalAmount 驼峰', r.body.monthAdjust['2026-09'].finalAmount === -55);
}
section('多用户数据隔离');
{
// 开放注册开第二个号
const req = new Request('http://t/api/register', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'other', password: 'other-pass-123' })
});
let res = await handleApi(req, store, { allowRegister: true });
eq('第二个账号注册成功', res.status, 201);
let r = await call('POST', '/api/login', { username: 'other', password: 'other-pass-123' }, null);
const otherToken = r.body.token;
eq('第二个账号 rev = 0', r.body.rev, 0);
r = await call('GET', '/api/data', undefined, otherToken);
eq('新账号看不到别人的记录', Object.keys(r.body.records).length, 0);
await call('POST', '/api/push', { records: { '2026-09-01': REC(12) } }, otherToken);
r = await call('GET', '/api/data', undefined, otherToken);
eq('新账号自己的 9/1 是 12h', r.body.records['2026-09-01'].hours, 12);
r = await call('GET', '/api/data');
eq('原账号 9/1 仍是 8h(未被串改)', r.body.records['2026-09-01'].hours, 8);
}
section('登出后 token 立即失效');
{
let r = await call('POST', '/api/logout');
eq('logout 200', r.status, 200);
r = await call('GET', '/api/data');
eq('登出后 401', r.status, 401);
}
section('会话过期');
{
const { hashToken } = await import('../core/auth.js');
const r0 = await call('POST', '/api/login', { username: 'kory', password: 'kory-pass-123' }, null);
const tk = r0.body.token;
// 手动把这条会话改成已过期
store.db.prepare('UPDATE sessions SET expires_at = ? WHERE token_hash = ?')
.run('2000-01-01T00:00:00.000Z', await hashToken(tk));
const r = await call('GET', '/api/data', undefined, tk);
eq('过期 token 401', r.status, 401);
ok('提示需重新登录', /过期|失效/.test(r.body.error), r.body.error);
}
section('404 与畸形请求体');
{
let r = await call('GET', '/api/nope');
eq('未知接口 404', r.status, 404);
const req = new Request('http://t/api/login', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{not json'
});
const res = await handleApi(req, store, {});
eq('畸形 JSON 400', res.status, 400);
}
// ==========================================================
store.close();
rmSync(tmp, { recursive: true, force: true });
console.log(`\n${'='.repeat(46)}`);
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
console.log('='.repeat(46));
process.exit(fail === 0 ? 0 : 1);
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Work Hours Tracker (Node: static + sync API)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/.openclaw/workspace/work-hours-tracker/server
ExecStart=/usr/bin/node --disable-warning=ExperimentalWarning node-server.js
# 端口沿用 9522:localStorage 按「协议+域名+端口」隔离,
# 换端口等于换 origin,浏览器里已有的打卡数据会全部看不见(而且旧端口没服务了,连导出都点不到)。
# 保持 9522 → 现有数据原地可用,零迁移。
Environment=PORT=9522
Environment=HOST=0.0.0.0
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+38
View File
@@ -0,0 +1,38 @@
// Cloudflare Workers 入口
//
// 与 node-server.js 是同一套 core/api.js,只是换了存储适配器(D1)与静态资源方式。
// 部署前:
// 1. wrangler d1 create work-hours # 拿到 database_id 填进 wrangler.toml
// 2. npm run cf:migrate # 建表
// 3. npm run cf:deploy
//
// 静态资源走 Workers Assets(wrangler.toml 里的 [assets]),
// 所以这里只处理 /api/*,其余交回 env.ASSETS。
import { handleApi } from './core/api.js';
import { D1Store } from './adapters/d1.js';
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname.startsWith('/api/')) {
if (!env.DB) {
return new Response(
JSON.stringify({ ok: false, error: 'D1 未绑定:请在 wrangler.toml 配置 [[d1_databases]] binding = "DB"' }),
{ status: 500, headers: { 'content-type': 'application/json; charset=utf-8' } }
);
}
return handleApi(request, new D1Store(env.DB), {
allowRegister: env.ALLOW_REGISTER === '1'
});
}
// 静态资源:绑定了 Assets 就交给它,否则给个明确提示而不是 404 让人猜
if (env.ASSETS) return env.ASSETS.fetch(request);
return new Response('静态资源未绑定(需在 wrangler.toml 配置 [assets])', {
status: 404,
headers: { 'content-type': 'text/plain; charset=utf-8' }
});
}
};
+27
View File
@@ -0,0 +1,27 @@
# Cloudflare Workers 部署配置
#
# 用前必做:
# 1. wrangler d1 create work-hours
# → 把输出里的 database_id 填到下面
# 2. npm run cf:migrate # 等价 wrangler d1 execute work-hours --remote --file=./core/schema.sql
# 3. npm run cf:deploy
#
# 注意:Workers 上的 core/api.js 完全不变,只有存储适配器从 node:sqlite 换成 D1。
name = "work-hours-tracker"
main = "worker.js"
compatibility_date = "2025-01-01"
# 静态资源(index.html 等)。目录指向项目根,即 server/ 的上一层。
[assets]
directory = "../"
binding = "ASSETS"
[[d1_databases]]
binding = "DB"
database_name = "work-hours"
database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID"
[vars]
# "1" 开放注册;留空/删除则只允许在「库里还没有任何用户」时注册首个账号
ALLOW_REGISTER = ""
+361
View File
@@ -0,0 +1,361 @@
/**
* 薪酬核算纯模块 —— 零依赖,浏览器与 Node 共用同一份。
*
* 存在的理由:这套算钱逻辑原来焊在 index.html 的 setup() 里,唯一能测它的办法是用正则
* 把内联 <script> 抠出来在 vm 里跑。那意味着「前端一拆,测试全废」——网挂在了要拆掉的墙上。
* 抽成模块后,测试直接喂数据进函数,跟前端用什么框架、拆成几个文件都无关。
*
* 硬性约束(改这个文件前先读):
* 1. 不碰 DOM、不碰 localStorage、不碰 window、不读「现在」。
* 需要「今天」的函数一律由调用方显式传 now,否则测试基准每天都不一样。
* 2. 纯函数,不改入参。records / monthAdjust 只读。
* 3. 金额一律返回数字,格式化只在 fmt/money/signed/moneyParts 里做。
*
* 业务口径(用户确认的事实,不是推测):
* 时薪 19.5 | 满勤门槛 27 天 | 满勤奖 1 元/工时(当月总工时 × 1)
* 查获 +10 元/件 | 漏查 -20 元/件 | 其他扣款按日填
* 上半月截止 15 日 | 发薪日 A = 22 号(本月上半月纯工时)
* 发薪日 B = 次月 10 号(本月下半月工时 + 满勤奖 + 已定稿奖惩)
* 只有「上班」计出勤;休息/请假不计工时也不计出勤天数
* 夜班跨天:工时全归开始打卡那一天
* 满勤未达标 → 满勤奖按 0;奖惩未经组长审核定稿 → 一律按 0(同一个道理)
*/
(function (root, factory) {
'use strict';
var api = factory();
if (typeof module === 'object' && module && module.exports) module.exports = api;
if (root) root.WHTPayroll = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
// ===== 默认规则 =====
var DEFAULTS = {
rate: 19.5, // 基础时薪,当月工资只含工时
fullDays: 27, // 满勤门槛(天)
fullBonusPerHour: 1, // 满勤奖 = 当月总工时 × 该单价(属奖励,次月结)
foundBonus: 10, // 查获违禁品 元/件
missedPenalty: 20, // 漏查违禁品 元/件
cutoff: 15, // 上半月截止日
payDayA: 22, // 上半月工时 → 当月该日发
payDayB: 10, // 下半月工时 + 当月奖惩 → 次月该日发
dayHours: 8, // 白班默认工时
nightHours: 10, // 夜班默认工时
defaultShift: 'day' // 打卡默认班次:'day' 白班 / 'night' 夜班
};
// 设置字段中文名:登录合并冲突对照要给人看,不能直接抱 key 上屏
var SETTING_LABELS = {
rate: '基础时薪', fullDays: '满勤门槛(天)', fullBonusPerHour: '满勤奖单价',
foundBonus: '查获奖励/件', missedPenalty: '漏查处罚/件', cutoff: '上半月截止日',
payDayA: '发薪日 A', payDayB: '发薪日 B', dayHours: '白班默认工时',
nightHours: '夜班默认工时', defaultShift: '默认班次'
};
// ===== 工时口径:6–12h,0.5 步进 =====
var HOUR_MIN = 6, HOUR_MAX = 12, HOUR_STEP = 0.5;
var hourOptions = [];
for (var h = HOUR_MIN; h <= HOUR_MAX + 1e-9; h += HOUR_STEP) hourOptions.push(Math.round(h * 10) / 10);
// 整数部/小数部拆开渲染(仅日历格子用):小数位占固定宽度槽位,
// 这样 8 与 8.5 的整数部左右对齐,不会错位。
function hInt(v) { return Math.floor(Number(v) || 0); }
function hFrac(v) { return (Number(v) || 0) % 1 === 0.5 ? '.5' : ''; }
/**
* 归一「设置里的班次默认工时」,保证默认值落在可选范围内。
* 只作用于设置,不改写任何已保存的打卡记录(旧数据里 14h 这类越界值必须原样保留)。
*/
function clampHours(v, fallback) {
var n = Number(v);
if (!isFinite(n)) return fallback === undefined ? DEFAULTS.dayHours : fallback;
var snapped = Math.round(n / HOUR_STEP) * HOUR_STEP;
return Math.min(HOUR_MAX, Math.max(HOUR_MIN, snapped));
}
/**
* 数值兜底:输入框清空 / 脏数据时回落默认值,不让计算变 0 或 NaN。
* 必须先判空 —— Number(null) 与 Number('') 都等于 0,
* 少了这一步「清空时薪输入框」会把时薪静默变成 0。
*/
function sn(v, d) {
if (v === null || v === undefined || v === '') return d;
var n = Number(v);
return isFinite(n) && n >= 0 ? n : d;
}
/** 设置 → 洗过的有效配置。所有算钱函数只认这个返回值,不直接读原始 settings。 */
function normalizeSettings(settings) {
var s = settings || {};
return {
rate: sn(s.rate, DEFAULTS.rate),
fullDays: Math.round(sn(s.fullDays, DEFAULTS.fullDays)) || DEFAULTS.fullDays,
fullBonusPerHour: sn(s.fullBonusPerHour, DEFAULTS.fullBonusPerHour),
foundBonus: sn(s.foundBonus, DEFAULTS.foundBonus),
missedPenalty: sn(s.missedPenalty, DEFAULTS.missedPenalty),
cutoff: Math.min(28, Math.max(1, Math.round(sn(s.cutoff, DEFAULTS.cutoff)))),
// payDayA/B 原本只用于文案,现在参与日历发薪日判定,必须做同样的洗值,
// 否则 payDayKind() 拿到 undefined,比较永不命中,角标与内描边都不出现。
payDayA: Math.min(31, Math.max(1, Math.round(sn(s.payDayA, DEFAULTS.payDayA)))),
payDayB: Math.min(31, Math.max(1, Math.round(sn(s.payDayB, DEFAULTS.payDayB)))),
dayHours: clampHours(sn(s.dayHours, DEFAULTS.dayHours), DEFAULTS.dayHours),
nightHours: clampHours(sn(s.nightHours, DEFAULTS.nightHours), DEFAULTS.nightHours),
defaultShift: s.defaultShift === 'night' ? 'night' : 'day'
};
}
// ===== 日期键 =====
function pad2(n) { return String(n).length < 2 ? '0' + n : String(n); }
function monthPrefix(y, m) { return y + '-' + pad2(m + 1) + '-'; }
function keyOf(y, m, d) { return monthPrefix(y, m) + pad2(d); }
function monthKeyOf(y, m) { return y + '-' + pad2(m + 1); }
function prevMonthOf(y, m) { return m === 0 ? { year: y - 1, month: 11 } : { year: y, month: m - 1 }; }
function nextMonthOf(y, m) { return m === 11 ? { year: y + 1, month: 0 } : { year: y, month: m + 1 }; }
function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }
function firstDayOfWeek(y, m) { return new Date(y, m, 1).getDay(); }
/** 取某月的奖惩终稿;跨年也安全,因为 key 是 'YYYY-MM' 全量拼出来的。 */
function getMonthAdjust(monthAdjust, y, m) {
return (monthAdjust && monthAdjust[monthKeyOf(y, m)]) || null;
}
// ===== 核心聚合 =====
/**
* 聚合某月。本月与上月共用同一套口径,跨月结转才不会两头对不上。
* @param {number} year
* @param {number} month 0–11
* @param {{records:object, monthAdjust?:object, cfg:object}} ctx
*/
function aggregateMonth(year, month, ctx) {
var c = ctx.cfg;
var records = ctx.records || {};
var prefix = monthPrefix(year, month);
var h1 = 0, h2 = 0, workDays = 0, dayShifts = 0, nightShifts = 0, restDays = 0, leaveDays = 0;
var foundCount = 0, missedCount = 0, otherPenalties = 0;
for (var key in records) {
if (key.indexOf(prefix) !== 0) continue;
var rec = records[key];
if (!rec) continue;
if (rec.status === 'rest') { restDays++; continue; }
if (rec.status === 'leave') { leaveDays++; continue; }
if (rec.status !== 'work') continue;
var day = Number(key.slice(prefix.length));
var hours = Number(rec.hours || 0);
// 夜班跨天:工时整段归入开始打卡那一天,不按自然日切开
if (day <= c.cutoff) h1 += hours; else h2 += hours;
workDays++;
if (rec.shift_type === 'night') nightShifts++; else dayShifts++;
foundCount += Number(rec.contraband_found || 0);
missedCount += Number(rec.contraband_missed || 0);
otherPenalties += Math.max(0, Number(rec.other_penalty || 0));
}
var totalHours = h1 + h2;
// 满勤奖只由出勤天数客观判定,不参与人工审核修正
var fullBonus = workDays >= c.fullDays ? totalHours * c.fullBonusPerHour : 0;
var foundBonus = foundCount * c.foundBonus;
var missedPenalty = missedCount * c.missedPenalty;
// 日常累计奖惩 = 查获 - 漏查 - 其他扣款(不含满勤奖)。
// 这只是「记录」,不是可发金额:当月只记账,实际奖金要等次月初组长审核提交后才确定。
var rawReview = foundBonus - missedPenalty - otherPenalties;
// 与「满勤未达标 → 满勤奖按 0」同一个道理:
// 奖惩未定稿 → 一律按 0,不进任何应得/发放口径;只有定稿后才用审核终稿金额。
var adj = getMonthAdjust(ctx.monthAdjust, year, month);
// 判定条件与重构前逐字一致,包括一个已知怪癖:finalAmount 为 null 时 Number(null)===0 且
// isFinite(0) 为真,于是「status:'final' + finalAmount:null」会被当成「已定稿¥P0」。
// UI 走不到这个分支(writeAdjust 会先拦下非数字),只有导入脏备份才碰得到。
// 重构阶段一律保持原行为,不在抽模块时偺带修 bug;该怪癖已被用例钉住。
var finalized = !!(adj && adj.status === 'final' && isFinite(Number(adj.finalAmount)));
var reviewAmount = finalized ? Number(adj.finalAmount) : 0;
var reviewDelta = finalized ? reviewAmount - rawReview : 0;
var reviewNet = reviewAmount;
// 发放净额 = 满勤奖 + 已定稿奖惩。次月 payDayB 这两笔与下半月工时一起发,
// 「挣得」与「结转」两套口径都复用它,保证同名同数。
var adjustNet = fullBonus + reviewNet;
return {
h1: h1, h2: h2, totalHours: totalHours, workDays: workDays,
dayShifts: dayShifts, nightShifts: nightShifts, restDays: restDays, leaveDays: leaveDays,
foundCount: foundCount, missedCount: missedCount, otherPenalties: otherPenalties,
fullBonus: fullBonus, foundBonus: foundBonus, missedPenalty: missedPenalty,
adjustNet: adjustNet, reviewNet: reviewNet,
rawReview: rawReview, reviewAmount: reviewAmount, reviewDelta: reviewDelta,
finalized: finalized,
adjustStatus: adj ? adj.status : null,
adjustNote: adj && adj.note ? adj.note : '',
finalizedAt: adj && adj.finalizedAt ? adj.finalizedAt : '',
basePay: totalHours * c.rate
};
}
/**
* 现金流与两套记账口径。
*
* 归属制「挣得」:本月 1–月底干出来的钱,不管什么时候发。
* 收付制「进账」:本月卡里实际进的钱,含上月结转。
* 两者永远不相等,必须各自具名,不能都叫「本月」——这是页面之前最主要的歧义源。
*
* @param {{cur:object, prev:object, cfg:object}} o cur/prev 为 aggregateMonth 的返回值
*/
function flowsOf(o) {
var cur = o.cur, prev = o.prev, c = o.cfg;
var thisA = Math.max(0, cur.h1 * c.rate);
var thisB = Math.max(0, prev.h2 * c.rate + prev.adjustNet);
var nextB = Math.max(0, cur.h2 * c.rate + cur.adjustNet);
// 挣得 = 工时收入 + 满勤奖(未达门槛为 0)+ 奖惩净额(未定稿为 0)
// adjustNet 本身 = fullBonus + reviewNet,所以三项相加严丝合缝等于这个值。
var earnedTotal = cur.basePay + cur.adjustNet;
// 结转 = 次月 payDayB 实际到账那一笔。直接复用 nextB,
// 从公式上保证「结转」与「次月实际上账」永远同一个数,不再出现账目缺口。
var carry = nextB;
return {
thisA: thisA, thisB: thisB, nextB: nextB,
earnedTotal: earnedTotal,
cashTotal: thisB + thisA,
carry: carry,
split: { paid: thisA, carry: carry },
// 处罚吃穿下半月工时时结转被 ¥0 兜住,于是 paid+carry > earnedTotal,页面需要明说
floored: cur.h2 * c.rate + cur.adjustNet < 0
};
}
/** 处罚明细,按日排序并带原因。只看「上班」日,金额 <= 0 的不列。 */
function penaltyDetails(year, month, ctx) {
var c = ctx.cfg;
var records = ctx.records || {};
var prefix = monthPrefix(year, month);
var out = [];
for (var key in records) {
if (key.indexOf(prefix) !== 0) continue;
var r = records[key];
if (!r || r.status !== 'work') continue;
var missed = Number(r.contraband_missed || 0);
var other = Math.max(0, Number(r.other_penalty || 0));
var amount = missed * c.missedPenalty + other;
if (amount <= 0) continue;
var parts = [];
if (missed > 0) parts.push('漏查' + missed + '件');
if (other > 0) parts.push(r.penalty_reason ? r.penalty_reason : '其他扣款');
out.push({ day: Number(key.slice(prefix.length)), reason: parts.join('·'), amount: amount });
}
return out.sort(function (a, b) { return a.day - b.day; });
}
// ===== 日历判定 =====
/**
* 上下半月分界格:7 列网格里 day+7 就在 day 正下方,
* 所以给 cutoff-6 … cutoff 这 7 格加底虚线,正好描出一条完整分界线,
* 且不必往网格里插额外元素,星期对齐不会被打乱。
*/
function isCutEdge(day, cfg) { return day <= cfg.cutoff && day + 7 > cfg.cutoff; }
/**
* 发薪日判定:'a' = 当月 payDayA(上半月工时);'b' = 当月 payDayB(上月下半月 + 上月奖惩)。
* 只做统计标注,不处理「遇周末顺延」,实际到账日以单位为准。
*/
function payDayKind(day, cfg) {
if (day === cfg.payDayA) return 'a';
if (day === cfg.payDayB) return 'b';
return null;
}
function fullDaysLeft(stats, cfg) { return Math.max(0, cfg.fullDays - stats.workDays); }
/** 上半月那笔到没到账。翻历史月份一律算已发,只有看当前月才有「待发」说法。 */
function payDayAPassed(year, month, cfg, now) {
var n = now || new Date();
var isCur = n.getFullYear() === year && n.getMonth() === month;
if (!isCur) {
return year < n.getFullYear() || (year === n.getFullYear() && month < n.getMonth());
}
return n.getDate() >= cfg.payDayA;
}
function isCurrentMonth(year, month, now) {
var n = now || new Date();
return n.getFullYear() === year && n.getMonth() === month;
}
function isSameDay(year, month, day, now) {
var n = now || new Date();
return n.getFullYear() === year && n.getMonth() === month && n.getDate() === day;
}
// ===== 格式化 =====
function fmt(n) { return (Number(n) || 0).toFixed(1); }
function money(n) { return '¥' + (Number(n) || 0).toFixed(1); }
function signed(n) {
var v = Number(n) || 0;
return (v >= 0 ? '+¥' : '-¥') + Math.abs(v).toFixed(1);
}
/**
* 金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
* 必须从 toFixed(1) 的结果上切,不能用 Math.floor 另算,
* 否则 99.96 会被 money() 进为 100.0、而拆分出 99.0,两处数字对不上。
*/
function moneyParts(n) {
var v = Number(n) || 0;
var s = Math.abs(v).toFixed(1);
var dot = s.indexOf('.');
return {
sign: v < 0 ? '-' : '',
int: s.slice(0, dot).replace(/\B(?=(\d{3})+(?!\d))/g, ','), // 千分位
frac: s.slice(dot)
};
}
/** 新建一条打卡草稿。班次决定默认工时。 */
function newDraft(shift, cfg) {
return {
status: 'work',
shift_type: shift,
hours: shift === 'night' ? cfg.nightHours : cfg.dayHours,
contraband_found: 0,
contraband_missed: 0,
other_penalty: null,
penalty_reason: ''
};
}
/**
* 保存前归一:非正扣款回落 null;非「上班」日清空全部奖惩字段。
* 原地改传入对象并返回它 —— 调用方持有的就是待落库的那份草稿。
*/
function normalizeDraft(draft) {
var op = Number(draft.other_penalty);
draft.other_penalty = (isFinite(op) && op > 0) ? op : null;
if (draft.status !== 'work') {
draft.contraband_found = 0;
draft.contraband_missed = 0;
draft.other_penalty = null;
draft.penalty_reason = '';
}
return draft;
}
return {
DEFAULTS: DEFAULTS,
SETTING_LABELS: SETTING_LABELS,
HOUR_MIN: HOUR_MIN, HOUR_MAX: HOUR_MAX, HOUR_STEP: HOUR_STEP,
hourOptions: hourOptions,
hInt: hInt, hFrac: hFrac, clampHours: clampHours, sn: sn,
normalizeSettings: normalizeSettings,
pad2: pad2, monthPrefix: monthPrefix, keyOf: keyOf, monthKeyOf: monthKeyOf,
prevMonthOf: prevMonthOf, nextMonthOf: nextMonthOf,
daysInMonth: daysInMonth, firstDayOfWeek: firstDayOfWeek,
getMonthAdjust: getMonthAdjust,
aggregateMonth: aggregateMonth,
flowsOf: flowsOf,
penaltyDetails: penaltyDetails,
isCutEdge: isCutEdge, payDayKind: payDayKind, fullDaysLeft: fullDaysLeft,
payDayAPassed: payDayAPassed, isCurrentMonth: isCurrentMonth, isSameDay: isSameDay,
fmt: fmt, money: money, signed: signed, moneyParts: moneyParts,
newDraft: newDraft, normalizeDraft: normalizeDraft
};
});
+504
View File
@@ -0,0 +1,504 @@
/**
* 客户端同步引擎(离线优先)
*
* 设计原则:
* 1. localStorage 仍然是本地的唯一真相 —— 断网、服务器挂了、没登录,页面照常能用。
* 同步只是「额外把数据推到服务器」,绝不是「必须联网才能记账」。
* 2. 每次本地写入都把改动的键记进 dirty 集合。同步时先 push dirty,再 pull 远端变更。
* push 在前 → 本地改动天然赢,不会被远端旧值覆盖。
* 3. 用服务端的单调 rev 做增量:只拉 rev 比本地已知值新的变更,不做全量对比。
* 4. 这一层不依赖 Vue,也不依赖浏览器独有 API(除 localStorage/fetch),
* 以后套 APP 壳(Capacitor / WebView / RN)可以整块复用。
*
* 暴露:window.WHTSync
*/
(function () {
'use strict';
const LS = {
records: 'work_records',
adjust: 'work_month_adjust',
settings: 'work_settings',
token: 'work_sync_token',
user: 'work_sync_user',
rev: 'work_sync_rev',
dirty: 'work_sync_dirty',
base: 'work_sync_base',
snapshot: 'work_local_snapshot',
hold: 'work_sync_hold'
};
const readLS = (k, fallback) => {
try {
const v = localStorage.getItem(k);
return v === null ? fallback : JSON.parse(v);
} catch { return fallback; }
};
const writeLS = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} };
// ---------- 脏键集合 ----------
// 形如 { records: ['2026-09-01', ...], adjust: ['2026-09'], settings: true }
function loadDirty() {
const d = readLS(LS.dirty, null);
return {
records: new Set((d && d.records) || []),
adjust: new Set((d && d.adjust) || []),
settings: !!(d && d.settings)
};
}
function saveDirty(d) {
writeLS(LS.dirty, {
records: [...d.records],
adjust: [...d.adjust],
settings: d.settings
});
}
// ---------- 状态 ----------
const state = {
// idle 未登录 / ready 已登录空闲 / syncing 同步中 / error 出错 / offline 连不上
status: 'idle',
user: readLS(LS.user, null),
token: readLS(LS.token, null),
rev: readLS(LS.rev, 0) || 0,
pending: 0,
lastSyncAt: null,
lastError: '',
// 冲突未解决时的硬刷阀。一旦置上,自动同步全部停止。
// 必须落 localStorage:否则用户选了「先不处理」后一刷新,阀就没了,
// 下一次 syncNow 会把远端值静默盖到本机冲突日上。
hold: readLS(LS.hold, null),
serverUsers: null // health 探测到的服务端用户数,用于判断该显示「注册」还是「登录」
};
const dirty = loadDirty();
state.pending = dirty.records.size + dirty.adjust.size + (dirty.settings ? 1 : 0);
const listeners = new Set();
function emit() {
const snap = { ...state };
listeners.forEach(fn => { try { fn(snap); } catch (e) { console.error(e); } });
}
function setStatus(s, err) {
state.status = s;
state.lastError = err || '';
emit();
}
function apiBase() {
const saved = readLS(LS.base, '');
if (saved) return String(saved).replace(/\/+$/, '');
return location.origin; // 默认同源:VPS 与 CF Workers 都是页面与 API 同一个域
}
async function api(path, { method = 'GET', body, auth = true, timeoutMs = 15000 } = {}) {
const headers = {};
if (body !== undefined) headers['content-type'] = 'application/json';
if (auth && state.token) headers.authorization = 'Bearer ' + state.token;
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
let res;
try {
res = await fetch(apiBase() + path, {
method, headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: ctl.signal
});
} catch (e) {
throw Object.assign(new Error(e.name === 'AbortError' ? '连接超时' : '无法连接服务器'), { offline: true });
} finally { clearTimeout(timer); }
let data = null;
try { data = await res.json(); } catch {}
if (res.status === 401) {
// 令牌失效:清掉登录态但保留本地数据与 dirty,用户重新登录后照样能推上去
state.token = null; state.user = null;
localStorage.removeItem(LS.token);
localStorage.removeItem(LS.user);
throw Object.assign(new Error((data && data.error) || '登录已失效'), { unauthorized: true });
}
if (!res.ok) throw new Error((data && data.error) || `请求失败 (${res.status})`);
return data;
}
// ---------- 行 → 前端结构 ----------
function rowToRecord(r) {
return {
status: r.status,
shift_type: r.shift_type,
hours: r.hours,
contraband_found: r.contraband_found || 0,
contraband_missed: r.contraband_missed || 0,
other_penalty: r.other_penalty === undefined ? null : r.other_penalty,
penalty_reason: r.penalty_reason || ''
};
}
function rowToAdjust(r) {
return {
status: r.status,
finalAmount: r.final_amount === undefined ? null : r.final_amount,
note: r.note || '',
finalizedAt: r.finalized_at || ''
};
}
// ---------- 本地 / 远端比对 ----------
// 首次登录要在「不写本地」的前提下判断两边差异,才能决定谁该赢、要不要问用户。
const REC_FIELDS = ['status', 'shift_type', 'hours', 'contraband_found', 'contraband_missed', 'other_penalty', 'penalty_reason'];
const ADJ_FIELDS = ['status', 'finalAmount', 'note']; // finalizedAt 是时间戳,两边天然不同,不算差异
function sameBy(fields, a, b) {
if (!a || !b) return false;
for (const f of fields) {
const x = a[f], y = b[f];
const xn = (x === '' || x === null || x === undefined) ? null : x;
const yn = (y === '' || y === null || y === undefined) ? null : y;
if (typeof xn === 'number' || typeof yn === 'number') {
if (Number(xn || 0) !== Number(yn || 0)) return false;
} else if (String(xn ?? '') !== String(yn ?? '')) return false;
}
return true;
}
// 应用远端变更到本地。onApply 由页面提供,负责写回 Vue 响应式数据并落 localStorage。
let applyHook = null;
function applyRemote(payload) {
if (!applyHook) return 0;
const recs = {}, adjs = {};
let n = 0;
for (const [day, row] of Object.entries(payload.records || {})) {
// 本地还没推上去的键不许被远端覆盖,否则用户刚记的会被旧数据吃掉
if (dirty.records.has(day)) continue;
recs[day] = row.deleted ? null : rowToRecord(row);
n++;
}
for (const [month, row] of Object.entries(payload.adjust || {})) {
if (dirty.adjust.has(month)) continue;
adjs[month] = row.deleted ? null : rowToAdjust(row);
n++;
}
const settings = (payload.settings && !dirty.settings) ? payload.settings : null;
if (settings) n++;
if (n > 0) applyHook({ records: recs, adjust: adjs, settings });
return n;
}
// ---------- 同步 ----------
let syncing = false;
let queued = false;
async function syncNow({ silent = false, force = false } = {}) {
if (!state.token) { if (!silent) setStatus('idle'); return { ok: false, reason: 'not-logged-in' }; }
// 冲突未解决 → 两个方向都停。只 push 不 pull 也不行:服务端会被本机静默覆盖。
if (state.hold && !force) return { ok: false, reason: 'conflict-hold', hold: state.hold };
if (syncing) { queued = true; return { ok: false, reason: 'busy' }; }
syncing = true;
setStatus('syncing');
// 拉取的起点必须是「推之前」的 rev。
// push 会把 rev 抬到一个新值并给推上去的行都打上它,若用推完的 rev 去 changes,
// 那些 rev 更小的行(别的设备早先推上来的)就会被整段跳过 —— 首次登录会一条都拉不到。
const revBefore = state.rev;
let pushed = 0;
try {
// 1) 推本地脏数据
if (dirty.records.size || dirty.adjust.size || dirty.settings) {
const allRecords = readLS(LS.records, {}) || {};
const allAdjust = readLS(LS.adjust, {}) || {};
const payload = { records: {}, monthAdjust: {} };
// 本地已删除的键 → 传 null,服务端写墓碑,其他设备才能同步到「这天被删了」
for (const day of dirty.records) payload.records[day] = allRecords[day] ?? null;
for (const m of dirty.adjust) payload.monthAdjust[m] = allAdjust[m] ?? null;
if (dirty.settings) payload.settings = readLS(LS.settings, {}) || {};
pushed = Object.keys(payload.records).length + Object.keys(payload.monthAdjust).length + (payload.settings ? 1 : 0);
await api('/api/push', { method: 'POST', body: payload });
dirty.records.clear(); dirty.adjust.clear(); dirty.settings = false;
saveDirty(dirty);
state.pending = 0;
// 这里刻意不写 state.rev:留给下面的 changes 用 revBefore 起拉,
// 自己刚推的行会被一起拉回来(值相同,幂等),但远端旧行不会漏。
}
// 2) 拉远端增量
const got = await api(`/api/changes?since=${revBefore}`);
const applied = applyRemote(got);
state.rev = got.rev;
writeLS(LS.rev, state.rev);
state.lastSyncAt = new Date().toISOString();
setStatus('ready');
return { ok: true, applied, pushed, rev: state.rev };
} catch (e) {
if (e.unauthorized) setStatus('idle', e.message);
else if (e.offline) setStatus('offline', e.message);
else setStatus('error', e.message);
return { ok: false, error: e.message };
} finally {
syncing = false;
if (queued) { queued = false; setTimeout(() => syncNow({ silent: true }), 300); }
}
}
let debounceTimer = null;
function scheduleSync(delay = 1500) {
if (!state.token || state.hold) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => syncNow({ silent: true }), delay);
}
// ---------- 对外接口 ----------
const WHTSync = {
get state() { return { ...state }; },
onChange(fn) { listeners.add(fn); fn({ ...state }); return () => listeners.delete(fn); },
/** 页面注册「如何把远端数据写进本地」 */
setApplyHook(fn) { applyHook = fn; },
setApiBase(v) {
const s = String(v || '').trim().replace(/\/+$/, '');
if (s) writeLS(LS.base, s); else localStorage.removeItem(LS.base);
emit();
},
getApiBase: apiBase,
/** 标记本地改动。key 为日期(YYYY-MM-DD) / 月份(YYYY-MM) / 'settings' */
markDirty(kind, key) {
if (kind === 'record') dirty.records.add(key);
else if (kind === 'adjust') dirty.adjust.add(key);
else if (kind === 'settings') dirty.settings = true;
else return;
saveDirty(dirty);
state.pending = dirty.records.size + dirty.adjust.size + (dirty.settings ? 1 : 0);
emit();
scheduleSync();
},
/**
* 把本机现有全部数据标记为待上传。
* 用于首次登录:此前本机的数据都是用户自己录的,一条都不能被远端悄悄吃掉。
* @param {object} opts
* settings 是否连设置一起推(远端已有设置时应传 false,避免本机默认值盖掉远端)
* excludeRecords / excludeAdjust 这些键不标脏 → 同步时让远端版本落地(用户选了「用服务器版本」)
*/
markAllLocalDirty({ settings = true, excludeRecords = [], excludeAdjust = [] } = {}) {
const exR = new Set(excludeRecords), exA = new Set(excludeAdjust);
const recs = readLS(LS.records, {}) || {};
const adjs = readLS(LS.adjust, {}) || {};
let nr = 0, na = 0;
for (const k of Object.keys(recs)) if (!exR.has(k)) { dirty.records.add(k); nr++; }
for (const k of Object.keys(adjs)) if (!exA.has(k)) { dirty.adjust.add(k); na++; }
if (settings) dirty.settings = true;
saveDirty(dirty);
state.pending = dirty.records.size + dirty.adjust.size + (dirty.settings ? 1 : 0);
emit();
return { records: nr, adjust: na };
},
/** 只读拉一份服务端快照:不写本地、不动 dirty、不改 rev。用于登录前先看清两边差异。 */
async peekRemote() {
return api('/api/data');
},
/**
* 比对本机与一份远端快照(peekRemote 的返回值)。
* @returns { onlyLocal, onlyRemote, conflict, same, remoteHasSettings }
* conflict = 两边都有这一天但内容不同 —— 只有这种情况才需要让用户决定
*/
diffLocal(dump) {
const out = {
onlyLocal: [], onlyRemote: [], conflict: [], same: [],
adjustOnlyLocal: [], adjustOnlyRemote: [], adjustConflict: [],
conflictDetail: [], adjustConflictDetail: [],
settingsDiff: [],
remoteHasSettings: !!(dump && dump.settings && Object.keys(dump.settings).length)
};
const localR = readLS(LS.records, {}) || {};
const localA = readLS(LS.adjust, {}) || {};
const localS = readLS(LS.settings, {}) || {};
const remoteR = (dump && dump.records) || {};
const remoteA = (dump && dump.adjust) || {};
const remoteS = (dump && dump.settings) || {};
for (const k of Object.keys(localR)) {
if (!(k in remoteR)) out.onlyLocal.push(k);
else {
const rem = rowToRecord(remoteR[k]);
if (sameBy(REC_FIELDS, localR[k], rem)) out.same.push(k);
else { out.conflict.push(k); out.conflictDetail.push({ key: k, local: localR[k], remote: rem }); }
}
}
for (const k of Object.keys(remoteR)) if (!(k in localR)) out.onlyRemote.push(k);
for (const k of Object.keys(localA)) {
if (!(k in remoteA)) out.adjustOnlyLocal.push(k);
else {
const rem = rowToAdjust(remoteA[k]);
if (!sameBy(ADJ_FIELDS, localA[k], rem)) {
out.adjustConflict.push(k);
out.adjustConflictDetail.push({ key: k, local: localA[k], remote: rem });
}
}
}
for (const k of Object.keys(remoteA)) if (!(k in localA)) out.adjustOnlyRemote.push(k);
// 设置只比两边都有的字段:远端没存过某字段不算冲突(旧版本推上去的数据可能就没那个字段)
for (const k of Object.keys(remoteS)) {
if (localS[k] === undefined) continue;
if (Number(localS[k]) !== Number(remoteS[k]) && String(localS[k]) !== String(remoteS[k])) {
out.settingsDiff.push({ key: k, local: localS[k], remote: remoteS[k] });
}
}
[out.onlyLocal, out.onlyRemote, out.conflict, out.same,
out.adjustOnlyLocal, out.adjustOnlyRemote, out.adjustConflict].forEach(a => a.sort());
out.conflictDetail.sort((a, b) => a.key < b.key ? -1 : 1);
return out;
},
/** 合并前留一份本机快照。远端覆盖本机这类操作出意外时,至少数据还在。 */
snapshotLocal(tag) {
const snap = {
at: new Date().toISOString(),
tag: tag || '',
records: readLS(LS.records, {}) || {},
adjust: readLS(LS.adjust, {}) || {},
settings: readLS(LS.settings, {}) || {}
};
writeLS(LS.snapshot, snap);
return snap;
},
getLocalSnapshot() { return readLS(LS.snapshot, null); },
/**
* 挂起自动同步。用户在冲突弹窗选了「先不处理」时必须调。
* 不挂起的后果:下一次任意改动触发 syncNow → pull 会把远端值盖到本机冲突日上,
* 用户看到的就是「我明明选了不处理,数据自己变了」。
*/
holdSync(reason) {
state.hold = { reason: reason || 'conflict', at: new Date().toISOString() };
writeLS(LS.hold, state.hold);
clearTimeout(debounceTimer);
emit();
},
releaseSync() {
state.hold = null;
localStorage.removeItem(LS.hold);
emit();
},
/** 批量标记(导入备份、清空月份等场景) */
markManyDirty({ records = [], adjust = [], settings = false }) {
records.forEach(k => dirty.records.add(k));
adjust.forEach(k => dirty.adjust.add(k));
if (settings) dirty.settings = true;
saveDirty(dirty);
state.pending = dirty.records.size + dirty.adjust.size + (dirty.settings ? 1 : 0);
emit();
scheduleSync();
},
async health() {
try {
const h = await api('/api/health', { auth: false, timeoutMs: 6000 });
state.serverUsers = h.users;
emit();
return h;
} catch (e) {
state.serverUsers = null;
emit();
throw e;
}
},
async register(username, password) {
const r = await api('/api/register', { method: 'POST', auth: false, body: { username, password } });
return r;
},
async login(username, password) {
const r = await api('/api/login', { method: 'POST', auth: false, body: { username, password } });
state.token = r.token;
state.user = r.user;
writeLS(LS.token, r.token);
writeLS(LS.user, r.user);
// 换账号/首次登录:从 0 开始拉全量,避免沿用上一个账号的 rev 导致漏数据
state.rev = 0;
writeLS(LS.rev, 0);
setStatus('ready');
return r;
},
async logout({ keepLocal = true } = {}) {
try { await api('/api/logout', { method: 'POST' }); } catch {}
state.token = null; state.user = null; state.rev = 0; state.hold = null;
localStorage.removeItem(LS.token);
localStorage.removeItem(LS.user);
localStorage.removeItem(LS.rev);
localStorage.removeItem(LS.hold);
if (!keepLocal) {
localStorage.removeItem(LS.records);
localStorage.removeItem(LS.adjust);
}
setStatus('idle');
},
/** 用服务器数据整体覆盖本地(换设备后的首次拉取) */
async pullAll() {
const dump = await api('/api/data');
if (applyHook) {
const recs = {}, adjs = {};
for (const [day, row] of Object.entries(dump.records || {})) recs[day] = rowToRecord(row);
for (const [m, row] of Object.entries(dump.adjust || {})) adjs[m] = rowToAdjust(row);
applyHook({ records: recs, adjust: adjs, settings: dump.settings || null, replace: true });
}
dirty.records.clear(); dirty.adjust.clear(); dirty.settings = false;
saveDirty(dirty);
state.pending = 0;
state.rev = dump.rev;
writeLS(LS.rev, state.rev);
state.lastSyncAt = new Date().toISOString();
// 全量覆盖已经把两边弄成一致了,冲突不再存在
state.hold = null;
localStorage.removeItem(LS.hold);
setStatus('ready');
return dump;
},
/** 把本地全部数据推到服务器(首次上传,或怀疑服务端缺数据时补齐) */
async pushAll() {
const records = readLS(LS.records, {}) || {};
const monthAdjust = readLS(LS.adjust, {}) || {};
const settings = readLS(LS.settings, {}) || {};
const r = await api('/api/push', { method: 'POST', body: { records, monthAdjust, settings } });
dirty.records.clear(); dirty.adjust.clear(); dirty.settings = false;
saveDirty(dirty);
state.pending = 0;
state.rev = r.rev;
writeLS(LS.rev, state.rev);
state.lastSyncAt = new Date().toISOString();
setStatus('ready');
return r;
},
syncNow,
scheduleSync
};
window.WHTSync = WHTSync;
// 已有令牌 → 进页面就静默同步一次;回到前台、网络恢复时再补一次
// 冲突挂起中则一步不动,等用户在合并面板里拍板
if (state.token) {
state.status = 'ready';
if (!state.hold) {
setTimeout(() => syncNow({ silent: true }), 800);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') scheduleSync(500);
});
window.addEventListener('online', () => scheduleSync(300));
}
}
})();
+50
View File
@@ -0,0 +1,50 @@
// 重构护栏:index.html 里跑出来的数 == shared/payroll.js 算出来的数。
//
// 这层网的用途只有一个:**证明前端可以安全地改用纯模块**。
// 现在 index.html 还带着自己那份内联算法(grep WHTPayroll 为 0 处),
// 两份实现并存期间,本文件保证它们逐字段等价;等 index.html 真的换成调 WHTPayroll,
// 这些断言会自动变成「同一份代码比自己」,届时连同 app-harness.js 一起删掉。
//
// 失败怎么读:
// 报「app 与 pure 不一致」→ 两份实现漂了。先确认哪份是对的,别急着改基准。
// 报「index.html 里没找到内联 <script>」→ 前端结构已变,看 app-harness.js 顶部说明。
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { loadApp } = require('./lib/app-harness.js');
const { probeApp, probePure } = require('./lib/probe.js');
const { SCENARIOS, NOW } = require('./lib/scenarios.js');
/** 差异摘要:deepStrictEqual 的报文很长,先把「哪几个字段不一样」摘出来放最前面。 */
function diffKeys(a, b, path = '') {
const out = [];
const keys = new Set([...Object.keys(a || {}), ...Object.keys(b || {})]);
for (const k of keys) {
const p = path ? `${path}.${k}` : k;
const va = a ? a[k] : undefined;
const vb = b ? b[k] : undefined;
if (va && vb && typeof va === 'object' && typeof vb === 'object') out.push(...diffKeys(va, vb, p));
else if (JSON.stringify(va) !== JSON.stringify(vb)) out.push(`${p}: app=${JSON.stringify(va)} pure=${JSON.stringify(vb)}`);
}
return out;
}
for (const sc of SCENARIOS) {
test(`app==pure · ${sc.name}`, () => {
const { app } = loadApp({
records: sc.records, year: sc.year, month: sc.month,
settings: sc.settings, adjust: sc.adjust, now: NOW
});
const fromApp = probeApp(app);
const fromPure = probePure(sc, NOW);
const diff = diffKeys(fromApp, fromPure);
assert.deepStrictEqual(fromApp, fromPure, diff.length ? '字段差异:\n ' + diff.join('\n ') : undefined);
});
}
test('场景库不是空的(防止 SCENARIOS 被误清后整层网静默失效)', () => {
assert.ok(SCENARIOS.length >= 30, `场景数 ${SCENARIOS.length},少于 30 说明用例被删了`);
const names = SCENARIOS.map((s) => s.name);
assert.equal(new Set(names).size, names.length, '场景名重复,基准里会互相盖掉');
});
+58
View File
@@ -0,0 +1,58 @@
// 基准对照:把 30 个场景的全部薪酬输出与冻结快照逐字段比。
//
// 与 payroll.test.js 的分工:
// payroll.test.js 写死期望值,说明「为什么该是这个数」——防的是规则本身算错。
// 本文件 不判断对错,只判断有没有变——防的是重构悄悄改了数。
// 快照由 tools/gen-baseline.js 生成,默认只校验、写要带 --write(历史上它默认写,
// 等于 `node --test` 一跑就把网重新描一遍,算错也全绿;那个坑已经堵上了)。
//
// 红了怎么办:先看差在哪个字段,确认是「有意的业务变更」还是「重构漏了」。
// 前者才允许 node tools/gen-baseline.js --write,且要能逐条说清每处差异。
// 「测试红了就重新生成基准」= 把安全网剪断。
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { loadApp } = require('./lib/app-harness.js');
const { probeApp, probePure } = require('./lib/probe.js');
const { SCENARIOS, NOW } = require('./lib/scenarios.js');
const BASELINE = path.join(__dirname, 'baseline', 'payroll-baseline.json');
test('基准文件存在且能解析', () => {
assert.ok(fs.existsSync(BASELINE), `缺基准文件 ${BASELINE},先跑 node tools/gen-baseline.js --write`);
const raw = JSON.parse(fs.readFileSync(BASELINE, 'utf8'));
assert.equal(raw.frozenNow, NOW, '基准的冻结时刻与 scenarios.js 的 NOW 不一致,两边不可比');
assert.equal(raw.cases.length, raw.scenarioCount);
assert.ok(raw.cases.length > 0);
});
const baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8'));
const byName = new Map(baseline.cases.map((c) => [c.name, c.probe]));
test('基准场景集与 scenarios.js 完全对应(防止悄悄少测)', () => {
const now = SCENARIOS.map((s) => s.name).sort();
const old = baseline.cases.map((c) => c.name).sort();
assert.deepEqual(now, old, '场景增删后必须显式重写基准:node tools/gen-baseline.js --write');
});
for (const sc of SCENARIOS) {
test(`基准 · ${sc.name}`, () => {
const want = byName.get(sc.name);
assert.ok(want, `基准里没有场景「${sc.name}」`);
// index.html 现跑值 vs 冻结值
const { app } = loadApp({
records: sc.records, year: sc.year, month: sc.month,
settings: sc.settings, adjust: sc.adjust, now: NOW
});
assert.deepStrictEqual(probeApp(app), want, 'index.html 的输出与基准不一致');
// 纯模块现算值 vs 同一份冻结值。
// 这一条是为「index.html 拆掉之后」准备的:那天上面那句会失效,
// 而只要这句还在,payroll.js 被误改一样会被抓住。
assert.deepStrictEqual(probePure(sc, NOW), want, 'shared/payroll.js 的输出与基准不一致');
});
}
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
// 在 Node 里跑 index.html 内联的那坨 setup(),拿到它返回的全部 computed。
//
// 这个文件是**唯一**焊在 index.html 结构上的东西:它靠正则抠出内联 <script>,
// 一旦前端拆成 SFC 就会立刻失效。这是刻意的——它的职责只剩「重构前后行为一致」对照,
// 前端拆完那天连同 app-parity 测试一起删掉,而 shared/payroll.js 上的那套断言不受影响。
//
// 与 verify_payroll.js 里那份内联桩件的区别:
// 1. Date 可冻结 → payDayAPassed / isCurrentMonth / cellClass 这些吃「今天」的输出变成确定值,
// 否则基准 JSON 每天跑出来都不一样,根本没法当基准。
// 2. 会把 shared/payroll.js 先注入沙箱,让重构后的 index.html 找得到 WHTPayroll。
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.join(__dirname, '..', '..');
const HTML = path.join(ROOT, 'index.html');
const PAYROLL = path.join(ROOT, 'shared', 'payroll.js');
/** 冻结「现在」:无参 new Date() 与 Date.now() 都回到固定时刻,带参构造照常。 */
function frozenDate(iso) {
const FIXED = new Date(iso).getTime();
return class FrozenDate extends Date {
constructor(...args) {
if (args.length === 0) super(FIXED);
else super(...args);
}
static now() { return FIXED; }
};
}
/** 极简 Vue 桩件:测试只需要能读写与求值,不需要真响应式。 */
function makeVue() {
const mounted = [];
const Vue = {
ref: (v) => ({ value: v }),
reactive: (o) => o,
computed: (fn) => ({ get value() { return fn(); } }),
watch: () => {},
onMounted: (fn) => mounted.push(fn),
createApp(opts) {
Vue._captured = opts.setup();
return { mount: () => Vue._captured };
}
};
return { Vue, mounted };
}
function makeStore(seed) {
const data = { ...seed };
return {
getItem: (k) => (k in data ? data[k] : null),
setItem: (k, v) => { data[k] = v; },
removeItem: (k) => { delete data[k]; },
_raw: () => data,
_records: () => JSON.parse(data.work_records || '{}'),
_settings: () => (data.work_settings ? JSON.parse(data.work_settings) : null),
_adjust: () => (data.work_month_adjust ? JSON.parse(data.work_month_adjust) : null)
};
}
/**
* 载入应用。
* @param {object} o
* @param {object} o.records 打卡记录,key 'YYYY-MM-DD'
* @param {number} o.year 当前查看年
* @param {number} o.month 当前查看月(0–11)
* @param {object} [o.settings] 覆盖设置,null/undefined 表示用默认
* @param {object} [o.adjust] 月度奖惩终稿,key 'YYYY-MM'
* @param {string} [o.now] 冻结的「现在」(ISO)
*/
function loadApp({ records = {}, year, month, settings = null, adjust = null, now = null }) {
const seed = { work_records: JSON.stringify(records) };
if (settings) seed.work_settings = JSON.stringify(settings);
if (adjust) seed.work_month_adjust = JSON.stringify(adjust);
const localStorage = makeStore(seed);
const { Vue, mounted } = makeVue();
const DateImpl = now ? frozenDate(now) : Date;
const sandbox = {
Vue, localStorage, console,
Date: DateImpl, JSON, Math, Number, RegExp, Object, Array, String, Boolean, Error,
navigator: {},
document: {
createElement: () => ({ style: {}, select() {}, click() {} }),
body: { appendChild() {}, removeChild() {} },
getElementById: () => null,
addEventListener() {}
},
confirm: () => true,
setTimeout: (fn) => { if (typeof fn === 'function') fn(); return 0; },
Blob: function () {},
URL: { createObjectURL: () => 'blob:x', revokeObjectURL() {} },
// 刻意不提供 window.WHTSync:测试跑「未登录/纯离线」路径,
// 顺带验证同步缺席时算钱逻辑完全不受影响。
location: { origin: 'http://test' },
fetch: () => Promise.reject(new Error('offline in test'))
};
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
// 先注入纯模块:重构后的 index.html 依赖它。重构前注入也无害(没人引用)。
if (fs.existsSync(PAYROLL)) {
vm.runInContext(fs.readFileSync(PAYROLL, 'utf8'), sandbox, { filename: 'shared/payroll.js' });
}
const src = fs.readFileSync(HTML, 'utf8');
const m = src.match(/<script>\s*([\s\S]*?)\s*<\/script>\s*<\/body>/);
if (!m) throw new Error('index.html 里没找到应用内联 <script>,harness 需要更新');
vm.runInContext(m[1], sandbox, { filename: 'index.html#app' });
mounted.forEach((fn) => fn()); // 触发 records / settings / adjust 载入
const app = Vue._captured;
app.currentYear.value = year;
app.currentMonth.value = month;
return { app, localStorage, sandbox };
}
module.exports = { loadApp, frozenDate, ROOT };
+164
View File
@@ -0,0 +1,164 @@
// 探针:把「一个月的全部薪酬输出」压成一个可比较的扁平对象。
//
// 两个实现必须产出结构完全相同的对象:
// probeApp(app) —— 从 index.html 跑出来的 setup() 返回值上读
// probePure(sc) —— 只用 shared/payroll.js 算
// 于是「重构有没有改变行为」就退化成一次 deepEqual。
'use strict';
const P = require('../../shared/payroll.js');
// 浮点噪声必须先掐掉:19.5 × 22 这类乘法在不同写法下可能差 1e-13,
// 那不是行为变化,却会让 deepEqual 全线报红。金额留 4 位足够(页面只显示 1 位)。
const r4 = (n) => {
const v = Number(n);
if (!Number.isFinite(v)) return v === undefined ? null : String(v);
return Math.round(v * 1e4) / 1e4;
};
const STAT_KEYS = [
'h1', 'h2', 'totalHours', 'workDays', 'dayShifts', 'nightShifts', 'restDays', 'leaveDays',
'foundCount', 'missedCount', 'otherPenalties',
'fullBonus', 'foundBonus', 'missedPenalty', 'adjustNet', 'reviewNet',
'rawReview', 'reviewAmount', 'reviewDelta', 'basePay'
];
const CFG_KEYS = [
'rate', 'fullDays', 'fullBonusPerHour', 'foundBonus', 'missedPenalty',
'cutoff', 'payDayA', 'payDayB', 'dayHours', 'nightHours', 'defaultShift'
];
function pickStats(s) {
const out = {};
for (const k of STAT_KEYS) out[k] = r4(s[k]);
out.finalized = s.finalized === true;
out.adjustStatus = s.adjustStatus === undefined ? null : s.adjustStatus;
return out;
}
function pickCfg(c) {
const out = {};
for (const k of CFG_KEYS) out[k] = typeof c[k] === 'string' ? c[k] : r4(c[k]);
return out;
}
const pickPenalties = (list) =>
Array.prototype.map.call(list, (d) => ({ day: d.day, reason: d.reason, amount: r4(d.amount) }));
/**
* 拉回宿主 realm。
* index.html 是在 vm 沙箱里跑的,它造出来的数组/对象带的是**沙箱的** Array.prototype,
* 而 deepStrictEqual 会连原型一起比 —— 不做这一步,所有场景都会因为原型不同而全红,
* 报错还长得像「结构相同但不相等」,非常容易误判成算法出了问题(实测踩过)。
*/
const hostify = (o) => JSON.parse(JSON.stringify(o));
/** 从跑起来的 index.html 应用实例上读。 */
function probeApp(app) {
const s = app.monthStats.value;
const p = app.prevStats.value;
const cfg = app.cfg.value;
const days = new Date(app.currentYear.value, app.currentMonth.value + 1, 0).getDate();
const allDays = Array.from({ length: days }, (_, i) => i + 1);
return hostify({
cfg: pickCfg(cfg),
cur: pickStats(s),
prev: pickStats(p),
prevLabel: { year: app.prevLabel.value.year, month: app.prevLabel.value.month },
nextLabel: { year: app.nextLabel.value.year, month: app.nextLabel.value.month },
flows: {
thisA: r4(app.payoutThisA.value),
thisB: r4(app.payoutThisB.value),
nextB: r4(app.payoutNextB.value),
earnedTotal: r4(app.earnedTotal.value),
cashTotal: r4(app.cashTotal.value),
carry: r4(app.earnedSplit.value.carry),
paid: r4(app.earnedSplit.value.paid),
floored: app.earnedFloored.value === true
},
penalties: pickPenalties(app.penaltyDetails.value),
calendar: {
days,
cutEdge: allDays.filter((d) => app.isCutEdge(d)),
payDayA: allDays.filter((d) => app.payDayKind(d) === 'a'),
payDayB: allDays.filter((d) => app.payDayKind(d) === 'b'),
penaltyFlag: allDays.filter((d) => app.hasPenalty(d))
},
misc: {
fullDaysLeft: app.fullDaysLeft.value,
payDayAPassed: app.payDayAPassed.value === true,
isCurrentMonth: app.isCurrentMonth.value === true,
recordCount: app.recordCount.value,
boardInt: String(app.boardMain.value.int),
boardFrac: String(app.boardMain.value.frac),
boardSign: String(app.boardMain.value.sign),
hourOptions: app.hourOptions.join(','),
daysInMonth: app.daysInMonth.value,
firstDayOfWeek: app.firstDayOfWeek.value
}
});
}
/** 只用 shared/payroll.js 算,不碰 Vue、不碰 index.html。 */
function probePure(sc, nowIso) {
const cfg = P.normalizeSettings(sc.settings || {});
const ctx = { records: sc.records || {}, monthAdjust: sc.adjust || {}, cfg };
const cur = P.aggregateMonth(sc.year, sc.month, ctx);
const prevL = P.prevMonthOf(sc.year, sc.month);
const nextL = P.nextMonthOf(sc.year, sc.month);
const prev = P.aggregateMonth(prevL.year, prevL.month, ctx);
const flows = P.flowsOf({ cur, prev, cfg });
const now = nowIso ? new Date(nowIso) : new Date();
const days = P.daysInMonth(sc.year, sc.month);
const allDays = Array.from({ length: days }, (_, i) => i + 1);
const records = sc.records || {};
const prefix = P.monthPrefix(sc.year, sc.month);
const hasPenalty = (day) => {
const rec = records[prefix + P.pad2(day)];
return !!rec && rec.status === 'work' &&
(Number(rec.contraband_missed || 0) > 0 || Number(rec.other_penalty || 0) > 0);
};
const parts = P.moneyParts(flows.earnedTotal);
return {
cfg: pickCfg(cfg),
cur: pickStats(cur),
prev: pickStats(prev),
prevLabel: { year: prevL.year, month: prevL.month },
nextLabel: { year: nextL.year, month: nextL.month },
flows: {
thisA: r4(flows.thisA),
thisB: r4(flows.thisB),
nextB: r4(flows.nextB),
earnedTotal: r4(flows.earnedTotal),
cashTotal: r4(flows.cashTotal),
carry: r4(flows.carry),
paid: r4(flows.split.paid),
floored: flows.floored === true
},
penalties: pickPenalties(P.penaltyDetails(sc.year, sc.month, ctx)),
calendar: {
days,
cutEdge: allDays.filter((d) => P.isCutEdge(d, cfg)),
payDayA: allDays.filter((d) => P.payDayKind(d, cfg) === 'a'),
payDayB: allDays.filter((d) => P.payDayKind(d, cfg) === 'b'),
penaltyFlag: allDays.filter(hasPenalty)
},
misc: {
fullDaysLeft: P.fullDaysLeft(cur, cfg),
payDayAPassed: P.payDayAPassed(sc.year, sc.month, cfg, now) === true,
isCurrentMonth: P.isCurrentMonth(sc.year, sc.month, now) === true,
recordCount: Object.keys(records).length,
boardInt: String(parts.int),
boardFrac: String(parts.frac),
boardSign: String(parts.sign),
hourOptions: P.hourOptions.join(','),
daysInMonth: days,
firstDayOfWeek: P.firstDayOfWeek(sc.year, sc.month)
}
};
}
module.exports = { probeApp, probePure, r4, STAT_KEYS };
+229
View File
@@ -0,0 +1,229 @@
// 测试场景库 —— payroll 模块、index.html 基准、重构后对照三方共用同一批数据。
//
// 为什么单独一个文件:基准快照与重构后的实测必须喂**完全相同**的输入,
// 否则对不上时分不清是「代码变了」还是「测试数据变了」。
'use strict';
const W = (h, extra) => Object.assign({
status: 'work', shift_type: 'day', hours: h,
contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: ''
}, extra || {});
const FINAL = (mk, amount) => ({
[mk]: { status: 'final', finalAmount: amount, note: '', finalizedAt: '2026-10-01T09:00:00.000Z' }
});
const DRAFT = (mk, amount) => ({
[mk]: { status: 'draft', finalAmount: amount, note: '', finalizedAt: '' }
});
/** 满勤月:1–N 日全上白班 8h */
function fullMonth(days, hours) {
const rec = {};
for (let d = 1; d <= days; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(hours === undefined ? 8 : hours);
return rec;
}
// 冻结的「现在」:不冻结的话 payDayAPassed / isCurrentMonth / 今日高亮
// 每天跑出来都不一样,基准 JSON 根本没法比。
// 取 2026-09-20 是刻意的:已过 payDayA(22)? 没过 —— 正好覆盖「上半月待发」这一支。
const NOW = '2026-09-20T10:00:00+08:00';
/**
* 每个场景都会被喂给三方:纯模块、重构前的 index.html、重构后的 index.html。
* 覆盖目标:12 个薪酬 computed 的全部分支。
*/
const SCENARIOS = [
{
name: '基础分段·上下半月各2天无奖惩',
records: { '2026-09-03': W(8), '2026-09-14': W(8), '2026-09-20': W(10), '2026-09-28': W(10) },
year: 2026, month: 8
},
{
name: '边界·15日算上半月/16日算下半月',
records: { '2026-09-15': W(12), '2026-09-16': W(12) },
year: 2026, month: 8
},
{
name: '夜班跨天·15日夜班10h整段进上半月',
records: { '2026-09-15': W(10, { shift_type: 'night' }) },
year: 2026, month: 8
},
{
name: '满勤达标·27天×8h',
records: fullMonth(27),
year: 2026, month: 8
},
{
name: '差1天不满勤·26天×8h',
records: fullMonth(26),
year: 2026, month: 8
},
{
name: '奖惩四项归集·未定稿只记录',
records: {
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
},
year: 2026, month: 8
},
{
name: '奖惩已定稿-60',
records: {
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
},
year: 2026, month: 8, adjust: FINAL('2026-09', -60)
},
{
name: '草稿不生效·draft+30',
records: { '2026-09-05': W(8, { contraband_found: 3 }) },
year: 2026, month: 8, adjust: DRAFT('2026-09', 30)
},
{
name: '审核改数·累计+30只认+20',
records: { '2026-09-05': W(8, { contraband_found: 3 }) },
year: 2026, month: 8, adjust: FINAL('2026-09', 20)
},
{
name: '跨月结转·8月未定稿',
records: {
'2026-08-10': W(8),
'2026-08-18': W(10), '2026-08-22': W(10),
'2026-08-27': W(10, { contraband_found: 5, contraband_missed: 1 }),
'2026-09-02': W(8), '2026-09-09': W(8)
},
year: 2026, month: 8
},
{
name: '跨月结转·8月已定稿+30',
records: {
'2026-08-10': W(8),
'2026-08-18': W(10), '2026-08-22': W(10),
'2026-08-27': W(10, { contraband_found: 5, contraband_missed: 1 }),
'2026-09-02': W(8), '2026-09-09': W(8)
},
year: 2026, month: 8, adjust: FINAL('2026-08', 30)
},
{
name: '跨年边界·2026年1月取2025年12月',
records: {
'2025-12-20': W(10), '2025-12-28': W(10, { contraband_found: 2 }),
'2026-01-05': W(8)
},
year: 2026, month: 0
},
{
name: '跨年边界·2025-12已定稿+20',
records: {
'2025-12-20': W(10), '2025-12-28': W(10, { contraband_found: 2 }),
'2026-01-05': W(8)
},
year: 2026, month: 0, adjust: FINAL('2025-12', 20)
},
{
name: '处罚超过下半月工资·未定稿',
records: { '2026-09-20': W(6, { contraband_missed: 10, other_penalty: 200 }) },
year: 2026, month: 8
},
{
name: '处罚超过下半月工资·定稿-400触发地板0',
records: { '2026-09-20': W(6, { contraband_missed: 10, other_penalty: 200 }) },
year: 2026, month: 8, adjust: FINAL('2026-09', -400)
},
{
name: '休息请假不计工时',
records: {
'2026-09-03': W(8),
'2026-09-04': { status: 'rest' },
'2026-09-05': { status: 'leave' },
'2026-09-06': { status: 'rest' }
},
year: 2026, month: 8
},
{
name: '脏数据·其他扣款负数夹为0',
records: { '2026-09-20': W(8, { other_penalty: -500 }) },
year: 2026, month: 8
},
{
name: '月份隔离·不串月',
records: { '2026-08-05': W(12), '2026-10-05': W(12), '2026-09-05': W(8) },
year: 2026, month: 8
},
{
name: '自定义设置·时薪30/满勤20/单价2/截止10/发薪25与5',
records: (() => {
const rec = {};
for (let d = 1; d <= 20; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(10);
return rec;
})(),
year: 2026, month: 8,
settings: { rate: 30, fullDays: 20, fullBonusPerHour: 2, cutoff: 10, payDayA: 25, payDayB: 5 }
},
{
name: '非法设置全部回落默认',
records: { '2026-09-05': W(8) },
year: 2026, month: 8,
settings: { rate: null, fullDays: 0, fullBonusPerHour: undefined, cutoff: 99, foundBonus: -5, missedPenalty: NaN }
},
{
name: '处罚明细排序与原因',
records: {
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' }),
'2026-09-08': W(8, { contraband_missed: 2 }),
'2026-09-12': W(8, { contraband_missed: 1, other_penalty: 30 }),
'2026-09-15': W(8)
},
year: 2026, month: 8
},
{
name: '空数据不崩',
records: {},
year: 2026, month: 8
},
{
name: '越界旧值原样保留·14h与4h',
records: { '2026-09-05': W(14), '2026-09-06': W(4), '2026-09-07': W(8.5) },
year: 2026, month: 8
},
{
name: '用户实测场景·3天22h含查获1件未定稿',
records: { '2026-09-01': W(8), '2026-09-02': W(8), '2026-09-03': W(6, { contraband_found: 1 }) },
year: 2026, month: 8
},
{
name: '用户实测场景·同上已定稿+10',
records: { '2026-09-01': W(8), '2026-09-02': W(8), '2026-09-03': W(6, { contraband_found: 1 }) },
year: 2026, month: 8, adjust: FINAL('2026-09', 10)
},
{
name: '满勤+查获5件未定稿·满勤计奖惩不计',
records: Object.assign(fullMonth(27), { '2026-09-05': W(8, { contraband_found: 5 }) }),
year: 2026, month: 8
},
{
name: '满勤+查获5件已定稿+50·两项全计',
records: Object.assign(fullMonth(27), { '2026-09-05': W(8, { contraband_found: 5 }) }),
year: 2026, month: 8, adjust: FINAL('2026-09', 50)
},
{
name: '历史月份·看8月(payDayA 已过)',
records: { '2026-08-05': W(8), '2026-08-20': W(10) },
year: 2026, month: 7
},
{
name: '未来月份·看10月(payDayA 未过)',
records: { '2026-10-05': W(8), '2026-10-20': W(10) },
year: 2026, month: 9
},
{
name: '班次默认工时越界·白班2h夜班20h',
records: {},
year: 2026, month: 8,
settings: { dayHours: 2, nightHours: 20 }
}
];
module.exports = { W, FINAL, DRAFT, fullMonth, SCENARIOS, NOW };
+512
View File
@@ -0,0 +1,512 @@
// 薪酬口径断言 —— 期望值全部写死在这个文件里。
//
// 与 baseline.test.js 的分工,这条界线很重要:
// baseline = 「现在算出来的还是不是原来那个数」,它不知道什么是对的,只知道有没有变。
// 本文件 = 「这个数为什么该是这个数」,每条都能对着业务规则念出来。
// 只有 baseline 的话,一开始就算错的规则会被永久固化成「正确」。所以两者都要有。
//
// 口径来源:shared/payroll.js 顶部注释里那份用户确认过的规则,不是从代码反推的。
// 时薪 19.5 | 满勤门槛 27 天 | 满勤奖 1 元/工时 | 查获 +10/件 | 漏查 -20/件
// 上半月截止 15 日 | payDayA=22 发本月上半月工时 | payDayB=次月 10 号发下半月工时+满勤奖+已定稿奖惩
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const P = require('../shared/payroll.js');
const { W, FINAL, DRAFT, fullMonth } = require('./lib/scenarios.js');
const CFG = P.normalizeSettings({});
const ctxOf = (records, adjust, cfg) => ({ records, monthAdjust: adjust || {}, cfg: cfg || CFG });
const agg = (records, adjust, y = 2026, m = 8, cfg) => P.aggregateMonth(y, m, ctxOf(records, adjust, cfg));
/** 本月 + 上月 + 现金流,一次算齐(跨月口径必须两头同源,见 flowsOf 注释)。 */
function flows(records, adjust, y = 2026, m = 8, cfg = CFG) {
const ctx = ctxOf(records, adjust, cfg);
const cur = P.aggregateMonth(y, m, ctx);
const pl = P.prevMonthOf(y, m);
const prev = P.aggregateMonth(pl.year, pl.month, ctx);
return { cur, prev, f: P.flowsOf({ cur, prev, cfg }) };
}
// ───────────────────────── 默认规则值 ─────────────────────────
test('默认规则值就是用户确认的那套', () => {
assert.equal(CFG.rate, 19.5);
assert.equal(CFG.fullDays, 27);
assert.equal(CFG.fullBonusPerHour, 1);
assert.equal(CFG.foundBonus, 10);
assert.equal(CFG.missedPenalty, 20);
assert.equal(CFG.cutoff, 15);
assert.equal(CFG.payDayA, 22);
assert.equal(CFG.payDayB, 10);
assert.equal(CFG.dayHours, 8);
assert.equal(CFG.nightHours, 10);
assert.equal(CFG.defaultShift, 'day');
});
test('工时可选 6–12h 步进 0.5,共 13 档', () => {
assert.equal(P.hourOptions.length, 13);
assert.equal(P.hourOptions[0], 6);
assert.equal(P.hourOptions.at(-1), 12);
assert.deepEqual(P.hourOptions, [6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10, 10.5, 11, 11.5, 12]);
});
// ───────────────────────── 上下半月分段 ─────────────────────────
test('15 日算上半月、16 日算下半月(截止日本身归上半月)', () => {
const s = agg({ '2026-09-15': W(12), '2026-09-16': W(12) });
assert.equal(s.h1, 12);
assert.equal(s.h2, 12);
assert.equal(s.totalHours, 24);
assert.equal(s.basePay, 24 * 19.5); // 468
});
test('夜班跨天:工时整段归开始打卡那一天,不按自然日切开', () => {
// 15 日夜班 10h 实际干到 16 日,但 10h 全进上半月
const s = agg({ '2026-09-15': W(10, { shift_type: 'night' }) });
assert.equal(s.h1, 10);
assert.equal(s.h2, 0);
assert.equal(s.nightShifts, 1);
assert.equal(s.dayShifts, 0);
});
test('月份隔离:相邻月记录不串进来', () => {
const s = agg({ '2026-08-05': W(12), '2026-10-05': W(12), '2026-09-05': W(8) });
assert.equal(s.totalHours, 8);
assert.equal(s.workDays, 1);
});
test('休息/请假不计工时也不计出勤天数', () => {
const s = agg({
'2026-09-03': W(8),
'2026-09-04': { status: 'rest' },
'2026-09-05': { status: 'leave' },
'2026-09-06': { status: 'rest' }
});
assert.equal(s.totalHours, 8);
assert.equal(s.workDays, 1);
assert.equal(s.restDays, 2);
assert.equal(s.leaveDays, 1);
});
// ───────────────────────── 满勤奖 ─────────────────────────
test('满勤达标:27 天 × 8h → 满勤奖 = 总工时 × 1 = 216', () => {
const s = agg(fullMonth(27));
assert.equal(s.workDays, 27);
assert.equal(s.totalHours, 216);
assert.equal(s.fullBonus, 216);
assert.equal(s.adjustNet, 216); // 无奖惩时 adjustNet 就是满勤奖
assert.equal(P.fullDaysLeft(s, CFG), 0);
});
test('差 1 天不满勤:26 天 → 满勤奖归零,不按比例给', () => {
const s = agg(fullMonth(26));
assert.equal(s.workDays, 26);
assert.equal(s.totalHours, 208);
assert.equal(s.fullBonus, 0);
assert.equal(s.adjustNet, 0);
assert.equal(P.fullDaysLeft(s, CFG), 1);
});
test('满勤只看出勤天数,不受奖惩审核影响', () => {
// 27 天满勤 + 查获 5 件未定稿:满勤奖照给,奖惩按 0
const s = agg(Object.assign(fullMonth(27), { '2026-09-05': W(8, { contraband_found: 5 }) }));
assert.equal(s.fullBonus, 216);
assert.equal(s.rawReview, 50); // 记录在案
assert.equal(s.reviewNet, 0); // 未定稿不发
assert.equal(s.adjustNet, 216); // 只含满勤奖
});
// ───────────────────────── 奖惩与审核定稿 ─────────────────────────
test('奖惩四项归集:查获 +10/件、漏查 -20/件、其他扣款按日填', () => {
const s = agg({
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
});
assert.equal(s.foundCount, 3);
assert.equal(s.missedCount, 2);
assert.equal(s.otherPenalties, 50);
assert.equal(s.foundBonus, 30);
assert.equal(s.missedPenalty, 40);
assert.equal(s.rawReview, 30 - 40 - 50); // -60
});
test('未定稿一律按 0:累计 -60 记着,但不进应得也不进发放', () => {
const { cur, f } = flows({
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
});
assert.equal(cur.rawReview, -60);
assert.equal(cur.finalized, false);
assert.equal(cur.reviewAmount, 0);
assert.equal(cur.reviewDelta, 0); // 未定稿不谈差额
assert.equal(cur.adjustNet, 0);
assert.equal(f.earnedTotal, 468); // 24h × 19.5,奖惩不掺和
});
test('已定稿 -60:按审核终稿计入,差额为 0', () => {
const recs = {
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
};
const { cur, f } = flows(recs, FINAL('2026-09', -60));
assert.equal(cur.finalized, true);
assert.equal(cur.reviewAmount, -60);
assert.equal(cur.reviewDelta, 0);
assert.equal(cur.adjustNet, -60);
assert.equal(f.earnedTotal, 468 - 60); // 408
});
test('审核改数:累计 +30 但组长只批 +20 → 认 20,差额 -10', () => {
const { cur } = flows({ '2026-09-05': W(8, { contraband_found: 3 }) }, FINAL('2026-09', 20));
assert.equal(cur.rawReview, 30);
assert.equal(cur.reviewAmount, 20);
assert.equal(cur.reviewDelta, -10);
assert.equal(cur.adjustNet, 20);
});
test('草稿不生效:status=draft 即使带金额也按 0', () => {
const { cur } = flows({ '2026-09-05': W(8, { contraband_found: 3 }) }, DRAFT('2026-09', 30));
assert.equal(cur.adjustStatus, 'draft');
assert.equal(cur.finalized, false);
assert.equal(cur.reviewAmount, 0);
assert.equal(cur.adjustNet, 0);
});
test('已知怪癖钉死:final + finalAmount:null 会被当成「已定稿 ¥0」', () => {
// Number(null)===0 且 isFinite(0) 为真 → finalized 判定命中。
// UI 走不到(writeAdjust 会拦),只有导入脏备份碰得到。
// 重构阶段保持原行为,改它属于业务变更,要单独提。
const { cur } = flows({ '2026-09-05': W(8, { contraband_found: 3 }) }, FINAL('2026-09', null));
assert.equal(cur.finalized, true);
assert.equal(cur.reviewAmount, 0);
assert.equal(cur.reviewDelta, -30); // 累计 +30 被抹成 0
assert.equal(cur.adjustNet, 0);
});
// ───────────────────────── 发薪流与地板 ─────────────────────────
test('两笔发薪:payDayA 只发本月上半月工时,payDayB 发上月下半月+上月奖惩', () => {
const { f } = flows({
'2026-09-03': W(8), '2026-09-14': W(8), // 上半月 16h
'2026-09-20': W(10), '2026-09-28': W(10) // 下半月 20h
});
assert.equal(f.thisA, 16 * 19.5); // 312
assert.equal(f.thisB, 0); // 8 月没记录
assert.equal(f.nextB, 20 * 19.5); // 390,次月 10 号发
assert.equal(f.earnedTotal, 36 * 19.5); // 702 归属制
assert.equal(f.cashTotal, f.thisA + f.thisB); // 312 收付制
});
test('跨月结转:上月下半月工时次月才进账,上月未定稿奖惩不进', () => {
const CROSS = {
'2026-08-10': W(8),
'2026-08-18': W(10), '2026-08-22': W(10),
'2026-08-27': W(10, { contraband_found: 5, contraband_missed: 1 }),
'2026-09-02': W(8), '2026-09-09': W(8)
};
const a = flows(CROSS);
assert.equal(a.prev.h2, 30);
assert.equal(a.prev.adjustNet, 0); // 8 月未定稿
assert.equal(a.f.thisB, 30 * 19.5); // 585
assert.equal(a.f.cashTotal, 16 * 19.5 + 585); // 897
const b = flows(CROSS, FINAL('2026-08', 30));
assert.equal(b.prev.adjustNet, 30);
assert.equal(b.f.thisB, 585 + 30); // 615
assert.equal(b.f.cashTotal, 312 + 615); // 927
// 9 月自己那份没变:定稿的是 8 月
assert.equal(b.f.earnedTotal, a.f.earnedTotal);
});
test('结转 ≡ 次月实际到账(carry 与 nextB 同一个数,不留账目缺口)', () => {
const { f } = flows(fullMonth(27));
assert.equal(f.carry, f.nextB);
assert.equal(f.split.carry, f.nextB);
assert.equal(f.split.paid, f.thisA);
});
test('处罚吃穿下半月工资:到账被 ¥0 兜住,earnedTotal 允许为负并升 floored 旗', () => {
const recs = { '2026-09-20': W(6, { contraband_missed: 10, other_penalty: 200 }) };
const undecided = flows(recs);
assert.equal(undecided.cur.rawReview, -400);
assert.equal(undecided.f.nextB, 117); // 未定稿 → 处罚不生效,6h×19.5 照发
assert.equal(undecided.f.floored, false);
const finalized = flows(recs, FINAL('2026-09', -400));
assert.equal(finalized.cur.adjustNet, -400);
assert.equal(finalized.f.nextB, 0); // max(0, 117-400) → 地板
assert.equal(finalized.f.floored, true);
assert.equal(finalized.f.earnedTotal, 117 - 400); // -283,归属制照实说
// 地板兜住之后 paid+carry 会大于 earnedTotal,页面必须明说,别让用户自己对不上
assert.ok(finalized.f.split.paid + finalized.f.carry > finalized.f.earnedTotal);
});
test('earnedTotal 恒等于 basePay + adjustNet(三项加总严丝合缝)', () => {
for (const [recs, adj] of [
[fullMonth(27), null],
[fullMonth(27), FINAL('2026-09', 50)],
[{ '2026-09-05': W(8, { contraband_missed: 4 }) }, FINAL('2026-09', -80)],
[{}, null]
]) {
const { cur, f } = flows(recs, adj);
assert.equal(f.earnedTotal, cur.basePay + cur.adjustNet);
assert.equal(cur.adjustNet, cur.fullBonus + cur.reviewNet);
}
});
// ───────────────────────── 跨年 ─────────────────────────
test('跨年边界:2026 年 1 月的上月是 2025 年 12 月', () => {
assert.deepEqual(P.prevMonthOf(2026, 0), { year: 2025, month: 11 });
assert.deepEqual(P.nextMonthOf(2026, 11), { year: 2027, month: 0 });
const recs = {
'2025-12-20': W(10), '2025-12-28': W(10, { contraband_found: 2 }),
'2026-01-05': W(8)
};
const a = flows(recs, null, 2026, 0);
assert.equal(a.prev.h2, 20);
assert.equal(a.f.thisB, 20 * 19.5); // 390
const b = flows(recs, FINAL('2025-12', 20), 2026, 0);
assert.equal(b.f.thisB, 390 + 20); // 跨年定稿也认
});
test('闰年天数与月首星期', () => {
assert.equal(P.daysInMonth(2028, 1), 29);
assert.equal(P.daysInMonth(2026, 1), 28);
assert.equal(P.daysInMonth(2026, 8), 30);
assert.equal(P.firstDayOfWeek(2026, 8), 2); // 2026-09-01 周二
});
test('日期键拼装', () => {
assert.equal(P.pad2(3), '03');
assert.equal(P.pad2(30), '30');
assert.equal(P.monthPrefix(2026, 8), '2026-09-');
assert.equal(P.keyOf(2026, 8, 5), '2026-09-05');
assert.equal(P.monthKeyOf(2026, 0), '2026-01');
});
// ───────────────────────── 脏数据兜底 ─────────────────────────
test('其他扣款为负时夹到 0,不能变成倒给钱', () => {
const s = agg({ '2026-09-20': W(8, { other_penalty: -500 }) });
assert.equal(s.otherPenalties, 0);
assert.equal(s.rawReview, 0);
});
test('sn 兜底:空串/null 回落默认,负数回落默认,0 是有效值', () => {
assert.equal(P.sn('', 9), 9);
assert.equal(P.sn(null, 9), 9);
assert.equal(P.sn(undefined, 9), 9);
assert.equal(P.sn(-1, 9), 9);
assert.equal(P.sn(NaN, 9), 9);
assert.equal(P.sn(0, 9), 0); // 关键:0 不能被当成「空」
assert.equal(P.sn('7.5', 9), 7.5);
});
test('clampHours 夹到 6–12 并对齐 0.5 步进', () => {
assert.equal(P.clampHours(2), 6);
assert.equal(P.clampHours(20), 12);
assert.equal(P.clampHours(8.3), 8.5);
assert.equal(P.clampHours(8.2), 8);
assert.equal(P.clampHours(NaN, 7), 7);
});
test('非法设置全部回落/夹紧,不让计算变 0 或 NaN', () => {
const c = P.normalizeSettings({
rate: null, fullDays: 0, fullBonusPerHour: undefined,
cutoff: 99, foundBonus: -5, missedPenalty: NaN,
payDayA: 0, payDayB: 99, dayHours: 2, nightHours: 20, defaultShift: 'x'
});
assert.equal(c.rate, 19.5);
assert.equal(c.fullDays, 27); // 0 天满勤没意义 → 回落
assert.equal(c.fullBonusPerHour, 1);
assert.equal(c.foundBonus, 10);
assert.equal(c.missedPenalty, 20);
assert.equal(c.cutoff, 28); // 夹到上限
assert.equal(c.payDayA, 1); // 夹到下限,否则日历判定永不命中
assert.equal(c.payDayB, 31);
assert.equal(c.dayHours, 6);
assert.equal(c.nightHours, 12);
assert.equal(c.defaultShift, 'day');
});
test('越界的旧打卡记录原样参与计算,不被设置的夹紧规则改写', () => {
// clampHours 只作用于设置里的「默认工时」,不碰已保存记录
const s = agg({ '2026-09-05': W(14), '2026-09-06': W(4), '2026-09-07': W(8.5) });
assert.equal(s.h1, 26.5);
assert.equal(s.basePay, 26.5 * 19.5);
});
test('空数据不崩,全部归零', () => {
const { cur, f } = flows({});
assert.equal(cur.totalHours, 0);
assert.equal(cur.workDays, 0);
assert.equal(cur.basePay, 0);
assert.equal(f.earnedTotal, 0);
assert.equal(f.cashTotal, 0);
assert.equal(f.floored, false);
});
// ───────────────────────── 自定义设置 ─────────────────────────
test('自定义设置全程生效:时薪30/满勤20天/满勤奖2/截止10日', () => {
const cfg = P.normalizeSettings({ rate: 30, fullDays: 20, fullBonusPerHour: 2, cutoff: 10, payDayA: 25, payDayB: 5 });
const recs = {};
for (let d = 1; d <= 20; d++) recs[`2026-09-${String(d).padStart(2, '0')}`] = W(10);
const { cur, f } = flows(recs, null, 2026, 8, cfg);
assert.equal(cur.h1, 100); // 1–10 日
assert.equal(cur.h2, 100); // 11–20 日
assert.equal(cur.workDays, 20);
assert.equal(cur.fullBonus, 200 * 2); // 400
assert.equal(cur.basePay, 200 * 30); // 6000
assert.equal(f.thisA, 100 * 30); // 3000
assert.equal(f.nextB, 100 * 30 + 400); // 3400
assert.equal(f.earnedTotal, 6400);
});
// ───────────────────────── 处罚明细 ─────────────────────────
test('处罚明细按日排序、原因拼接、金额<=0 不列', () => {
const list = P.penaltyDetails(2026, 8, ctxOf({
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' }),
'2026-09-08': W(8, { contraband_missed: 2 }),
'2026-09-12': W(8, { contraband_missed: 1, other_penalty: 30 }),
'2026-09-15': W(8), // 无处罚 → 不列
'2026-09-18': { status: 'rest', contraband_missed: 9 } // 非上班日 → 不看
}));
assert.deepEqual(list, [
{ day: 8, reason: '漏查2件', amount: 40 },
{ day: 12, reason: '漏查1件·其他扣款', amount: 50 },
{ day: 25, reason: '迟到', amount: 50 }
]);
});
// ───────────────────────── 日历判定 ─────────────────────────
test('分界虚线正好 7 格:cutoff-6 … cutoff', () => {
const edge = (cfg) => Array.from({ length: 31 }, (_, i) => i + 1).filter((d) => P.isCutEdge(d, cfg));
assert.deepEqual(edge(CFG), [9, 10, 11, 12, 13, 14, 15]);
assert.deepEqual(edge(P.normalizeSettings({ cutoff: 10 })), [4, 5, 6, 7, 8, 9, 10]);
});
test('发薪日角标 a/b 各认各的日子', () => {
assert.equal(P.payDayKind(22, CFG), 'a');
assert.equal(P.payDayKind(10, CFG), 'b');
assert.equal(P.payDayKind(15, CFG), null);
});
test('payDayAPassed:当月看日子,历史月一律已发,未来月一律未发', () => {
const NOW = new Date('2026-09-20T10:00:00+08:00'); // 20 日 < payDayA 22
assert.equal(P.payDayAPassed(2026, 8, CFG, NOW), false); // 当月未到
assert.equal(P.payDayAPassed(2026, 7, CFG, NOW), true); // 8 月
assert.equal(P.payDayAPassed(2025, 11, CFG, NOW), true); // 去年 12 月
assert.equal(P.payDayAPassed(2026, 9, CFG, NOW), false); // 10 月
assert.equal(P.payDayAPassed(2027, 0, CFG, NOW), false); // 明年 1 月
const LATER = new Date('2026-09-22T00:30:00+08:00');
assert.equal(P.payDayAPassed(2026, 8, CFG, LATER), true); // 到日子了
});
test('isCurrentMonth / isSameDay 只认传进来的 now', () => {
const NOW = new Date('2026-09-20T10:00:00+08:00');
assert.equal(P.isCurrentMonth(2026, 8, NOW), true);
assert.equal(P.isCurrentMonth(2026, 7, NOW), false);
assert.equal(P.isSameDay(2026, 8, 20, NOW), true);
assert.equal(P.isSameDay(2026, 8, 21, NOW), false);
});
// ───────────────────────── 格式化 ─────────────────────────
test('金额格式化保留 1 位,null/undefined 当 0', () => {
assert.equal(P.fmt(undefined), '0.0');
assert.equal(P.money(null), '¥0.0');
assert.equal(P.money(1234.56), '¥1234.6');
assert.equal(P.signed(0), '+¥0.0');
assert.equal(P.signed(-5), '-¥5.0');
assert.equal(P.signed(30), '+¥30.0');
});
test('moneyParts 从 toFixed(1) 的结果上切,不另算整数部', () => {
// 99.96 若用 Math.floor 另算会得到 99,与 money() 的 ¥100.0 打架
assert.deepEqual(P.moneyParts(99.96), { sign: '', int: '100', frac: '.0' });
assert.deepEqual(P.moneyParts(-1234.56), { sign: '-', int: '1,234', frac: '.6' });
assert.deepEqual(P.moneyParts(0), { sign: '', int: '0', frac: '.0' });
assert.deepEqual(P.moneyParts(1234567.8), { sign: '', int: '1,234,567', frac: '.8' });
});
test('日历格子工时拆分:只认整数与 .5', () => {
assert.equal(P.hInt(8.5), 8);
assert.equal(P.hFrac(8.5), '.5');
assert.equal(P.hFrac(8), '');
assert.equal(P.hFrac(8.25), ''); // 非 .5 不显示,避免半个槽位错位
});
// ───────────────────────── 草稿 ─────────────────────────
test('newDraft 按班次给默认工时', () => {
assert.equal(P.newDraft('day', CFG).hours, 8);
assert.equal(P.newDraft('night', CFG).hours, 10);
assert.equal(P.newDraft('night', CFG).shift_type, 'night');
assert.equal(P.newDraft('day', CFG).status, 'work');
assert.equal(P.newDraft('day', P.normalizeSettings({ dayHours: 12 })).hours, 12);
});
test('normalizeDraft:非正扣款回落 null,非上班日清空全部奖惩', () => {
assert.equal(P.normalizeDraft({ status: 'work', other_penalty: -5 }).other_penalty, null);
assert.equal(P.normalizeDraft({ status: 'work', other_penalty: 0 }).other_penalty, null);
assert.equal(P.normalizeDraft({ status: 'work', other_penalty: '30' }).other_penalty, 30);
assert.deepEqual(
P.normalizeDraft({ status: 'rest', contraband_found: 3, contraband_missed: 1, other_penalty: 50, penalty_reason: 'x' }),
{ status: 'rest', contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: '' }
);
});
test('normalizeDraft 是原地改(调用方持有的就是待落库那份)', () => {
const d = { status: 'work', other_penalty: -1 };
assert.equal(P.normalizeDraft(d), d);
assert.equal(d.other_penalty, null);
});
// ───────────────────────── 纯度:不改入参 ─────────────────────────
test('算钱函数不改 records / monthAdjust(硬性约束 2)', () => {
const records = {
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2, other_penalty: -9 }),
'2026-09-04': { status: 'rest' }
};
const adjust = FINAL('2026-09', -60);
const settings = { rate: 21, cutoff: 12 };
const snap = JSON.stringify({ records, adjust, settings });
const cfg = P.normalizeSettings(settings);
const ctx = ctxOf(records, adjust, cfg);
P.aggregateMonth(2026, 8, ctx);
P.aggregateMonth(2026, 7, ctx);
P.penaltyDetails(2026, 8, ctx);
P.flowsOf({ cur: P.aggregateMonth(2026, 8, ctx), prev: P.aggregateMonth(2026, 7, ctx), cfg });
P.getMonthAdjust(adjust, 2026, 8);
assert.equal(JSON.stringify({ records, adjust, settings }), snap);
});
test('normalizeSettings 返回新对象,不写回原 settings', () => {
const settings = { rate: 30 };
const c = P.normalizeSettings(settings);
assert.notEqual(c, settings);
assert.deepEqual(settings, { rate: 30 }); // 没被塞进默认值
assert.equal(c.fullDays, 27);
});
test('normalizeSettings 不传参也能用', () => {
assert.deepEqual(P.normalizeSettings(), P.normalizeSettings({}));
assert.deepEqual(P.normalizeSettings(null), P.normalizeSettings({}));
});
+64
View File
@@ -0,0 +1,64 @@
// 生成/校验基准快照:把**当前** index.html 的薪酬输出固化成 JSON。
//
// 这是整张测试网的锚点。重构(抽模块、上 Vite、拆 SFC)之后再跑一遍对照,
// 任何一个数字变了都会被 test/app-parity.test.js 抓出来。
//
// node tools/gen-baseline.js # 只校验,不写(默认,安全)
// node tools/gen-baseline.js --check # 同上,显式写法
// node tools/gen-baseline.js --write # 真的重写基准(需要明确意图)
//
// ⚠️ 为什么这个文件不能放在 test/ 目录里:
// Node 的 test runner 在不给路径时会按 `**/test/**/*.?(c|m)js` 收集文件,
// 于是 `test/gen-baseline.js` 会被当成一个测试文件**直接执行**(实测确认)。
// 它当年的默认行为是「写基准」,等于 `node --test` 一跑就把安全网重新描一遍——
// 哪怕代码算错了钱,基准也会被改成错的然后「测试全绿」。
// 双重保险:① 挪出 test/,runner 收集不到;② 默认只校验,写要带 --write。
//
// 什么时候才允许重写基准:业务规则真的变了,而且你能逐条说清每个差异为什么该变。
// 「重构后测试红了就重新生成基准」等于把安全网剪断,那还不如没有。
'use strict';
const fs = require('fs');
const path = require('path');
const { loadApp } = require('../test/lib/app-harness.js');
const { probeApp } = require('../test/lib/probe.js');
const { SCENARIOS, NOW } = require('../test/lib/scenarios.js');
const OUT_DIR = path.join(__dirname, '..', 'test', 'baseline');
const OUT = path.join(OUT_DIR, 'payroll-baseline.json');
function build() {
const cases = SCENARIOS.map((sc) => {
const { app } = loadApp({
records: sc.records, year: sc.year, month: sc.month,
settings: sc.settings, adjust: sc.adjust, now: NOW
});
return { name: sc.name, probe: probeApp(app) };
});
return { frozenNow: NOW, scenarioCount: cases.length, cases };
}
const write = process.argv.includes('--write');
const data = build();
const json = JSON.stringify(data, null, 2) + '\n';
if (!write) {
if (!fs.existsSync(OUT)) {
console.error('基准文件不存在:' + OUT + '\n首次生成请跑 node tools/gen-baseline.js --write');
process.exit(1);
}
const old = fs.readFileSync(OUT, 'utf8');
if (old === json) {
console.log(`基准一致,${data.scenarioCount} 个场景无差异`);
process.exit(0);
}
console.error('基准与当前 index.html 输出不一致。跑 npm test 看具体差在哪。');
console.error('确认每处差异都该变之后,再 node tools/gen-baseline.js --write');
process.exit(1);
}
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(OUT, json);
console.log(`已写入基准:${OUT}`);
console.log(` 场景数 ${data.scenarioCount}`);
console.log(` 冻结时刻 ${data.frozenNow}`);
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
# 一次性重构脚本:把 index.html 内联的薪酬算法换成调 shared/payroll.js。
# 每处替换都必须在原文里出现且仅出现一次,否则整个脚本不落盘 —— 宁可不改,不能改错。
import sys, io
PATH = '/root/.openclaw/workspace/work-hours-tracker/index.html'
src = io.open(PATH, encoding='utf-8').read()
orig = src
E = [] # (标签, 旧, 新)
E.append(('工时口径+cfg', ''' const HOUR_MIN = 6, HOUR_MAX = 12, HOUR_STEP = 0.5;
const hourOptions = [];
for (let h = HOUR_MIN; h <= HOUR_MAX + 1e-9; h += HOUR_STEP) hourOptions.push(Math.round(h * 10) / 10);
// 整数部与小数部拆开渲染(仅用于日历格子):小数位占固定宽度槽位,
// 这样 8 与 8.5 的整数部左右对齐,不会形成错位。
const hInt = (v) => Math.floor(Number(v) || 0);
const hFrac = (v) => (Number(v) || 0) % 1 === 0.5 ? '.5' : '';
// 仅用于归一「设置里的班次默认工时」,保证默认值落在可选范围内。
// 不对已保存的打卡记录做任何改写。
const clampHours = (v, fallback) => {
const n = Number(v);
if (!Number.isFinite(n)) return fallback === undefined ? DEFAULTS.dayHours : fallback;
const snapped = Math.round(n / HOUR_STEP) * HOUR_STEP;
return Math.min(HOUR_MAX, Math.max(HOUR_MIN, snapped));
};
// ===== 数值兜底:输入框清空 / 脏数据时回落默认值,不让计算变 0 或 NaN =====
// 注意 Number(null) 与 Number('') 都等于 0,必须先判空,否则清空输入框会把时薪静默变成 0。
const sn = (v, d) => {
if (v === null || v === undefined || v === '') return d;
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? n : d;
};
const cfg = computed(() => ({
rate: sn(settings.rate, DEFAULTS.rate),
fullDays: Math.round(sn(settings.fullDays, DEFAULTS.fullDays)) || DEFAULTS.fullDays,
fullBonusPerHour: sn(settings.fullBonusPerHour, DEFAULTS.fullBonusPerHour),
foundBonus: sn(settings.foundBonus, DEFAULTS.foundBonus),
missedPenalty: sn(settings.missedPenalty, DEFAULTS.missedPenalty),
cutoff: Math.min(28, Math.max(1, Math.round(sn(settings.cutoff, DEFAULTS.cutoff)))),
// payDayA/payDayB 原本只用于文案展示,现在参与日历发薪日判定,必须进 cfg 做同样的洗值,
// 否则 payDayKind() 拿到 undefined,比较永远不命中,角标与内描边都不会出现。
payDayA: Math.min(31, Math.max(1, Math.round(sn(settings.payDayA, DEFAULTS.payDayA)))),
payDayB: Math.min(31, Math.max(1, Math.round(sn(settings.payDayB, DEFAULTS.payDayB)))),
dayHours: clampHours(sn(settings.dayHours, DEFAULTS.dayHours), DEFAULTS.dayHours),
nightHours: clampHours(sn(settings.nightHours, DEFAULTS.nightHours), DEFAULTS.nightHours),
defaultShift: settings.defaultShift === 'night' ? 'night' : 'day'
}));''', ''' // 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));'''))
E.append(('newDraft', ''' function newDraft(shift) {
return {
status: 'work',
shift_type: shift,
hours: shift === 'night' ? cfg.value.nightHours : cfg.value.dayHours,
contraband_found: 0,
contraband_missed: 0,
other_penalty: null,
penalty_reason: ''
};
}''', ''' const newDraft = (shift) => P.newDraft(shift, cfg.value);'''))
E.append(('日期辅助', ''' const daysInMonth = computed(() => new Date(currentYear.value, currentMonth.value + 1, 0).getDate());
const firstDayOfWeek = computed(() => new Date(currentYear.value, currentMonth.value, 1).getDay());
const monthPrefix = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}-`;
const keyOf = (y, m, d) => monthPrefix(y, m) + String(d).padStart(2, '0');
const monthKeyOf = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}`;
const getMonthAdjust = (y, m) => monthAdjust.value[monthKeyOf(y, m)] || null;
const getRecord = (day) => records.value[keyOf(currentYear.value, currentMonth.value, day)];
const prevMonthOf = (y, m) => m === 0 ? { year: y - 1, month: 11 } : { year: y, month: m - 1 };
const nextMonthOf = (y, m) => m === 11 ? { year: y + 1, month: 0 } : { year: y, month: m + 1 };
const isToday = (day) => {
const n = new Date();
return n.getFullYear() === currentYear.value && n.getMonth() === currentMonth.value && n.getDate() === day;
};
const isCurrentMonth = computed(() => {
const n = new Date();
return n.getFullYear() === currentYear.value && n.getMonth() === currentMonth.value;
});''', ''' const { 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));'''))
E.append(('格式化', ''' const fmt = (n) => (Number(n) || 0).toFixed(1);
const money = (n) => '¥' + (Number(n) || 0).toFixed(1);
const signed = (n) => { const v = Number(n) || 0; return (v >= 0 ? '+¥' : '-¥') + Math.abs(v).toFixed(1); };''',
''' // moneyParts 把金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
// 它必须从 toFixed(1) 的结果上切,不能用 Math.floor 另算,
// 否则 99.96 会被 money() 进为 100.0、而拆分出 99.0,两处数字对不上。
const { fmt, money, signed, moneyParts } = P;'''))
E.append(('aggregateMonth', ''' // ===== 核心聚合:本月 / 上月共用同一套口径 =====
// 夜班跨天:工时全部归入开始打卡的那一天。
const aggregateMonth = (year, month) => {
const c = cfg.value;
const prefix = monthPrefix(year, month);
let h1 = 0, h2 = 0, workDays = 0, dayShifts = 0, nightShifts = 0, restDays = 0, leaveDays = 0;
let foundCount = 0, missedCount = 0, otherPenalties = 0;
for (const key in records.value) {
if (key.indexOf(prefix) !== 0) continue;
const rec = records.value[key];
if (!rec) continue;
if (rec.status === 'rest') { restDays++; continue; }
if (rec.status === 'leave') { leaveDays++; continue; }
if (rec.status !== 'work') continue;
const day = Number(key.slice(prefix.length));
const hours = Number(rec.hours || 0);
if (day <= c.cutoff) h1 += hours; else h2 += hours;
workDays++;
if (rec.shift_type === 'night') nightShifts++; else dayShifts++;
foundCount += Number(rec.contraband_found || 0);
missedCount += Number(rec.contraband_missed || 0);
otherPenalties += Math.max(0, Number(rec.other_penalty || 0));
}
const totalHours = h1 + h2;
// 满勤奖只由出勤天数客观判定,不参与人工审核修正
const fullBonus = workDays >= c.fullDays ? totalHours * c.fullBonusPerHour : 0;
const foundBonus = foundCount * c.foundBonus;
const missedPenalty = missedCount * c.missedPenalty;
// 日常累计奖惩 = 查获 - 漏查 - 其他扣款(不含满勤奖)。
// 这只是「记录」,不是可发金额:查获多少件当月只记账,实际奖金要等次月初组长
// 审核提交、扣掉漏查与其他扣款后才确定。
const rawReview = foundBonus - missedPenalty - otherPenalties;
// 与「满勤未达标 → 满勤奖按 0 计」同一个道理:
// 奖惩未定稿 → 一律按 0 计,不进任何应得/发放口径;只有定稿后才用审核终稿金额。
const adj = getMonthAdjust(year, month);
const finalized = !!(adj && adj.status === 'final' && Number.isFinite(Number(adj.finalAmount)));
const reviewAmount = finalized ? Number(adj.finalAmount) : 0;
const reviewDelta = finalized ? reviewAmount - rawReview : 0;
// 计入应得的奖惩净额:未定稿恒为 0,页面单列这一项并标明「未定稿不计」。
const reviewNet = reviewAmount;
// 发放净额 = 满勤奖 + 已定稿奖惩。次月 payDayB 这两笔与下半月工时一起发,
// 「挣得」与「结转」两套口径都复用它,保证同名同数。
const adjustNet = fullBonus + reviewNet;
return {
h1, h2, totalHours, workDays, dayShifts, nightShifts, restDays, leaveDays,
foundCount, missedCount, otherPenalties,
fullBonus, foundBonus, missedPenalty, adjustNet, reviewNet,
rawReview, reviewAmount, reviewDelta, finalized,
adjustStatus: adj ? adj.status : null,
adjustNote: adj && adj.note ? adj.note : '',
finalizedAt: adj && adj.finalizedAt ? adj.finalizedAt : '',
basePay: totalHours * c.rate
};
};''', ''' // ===== 核心聚合:本月 / 上月共用同一套口径 =====
// 口径本体在 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());'''))
E.append(('flows', ''' // 发放口径:
// 当月 payDayA = 当月 1–cutoff 工时 × rate
// 当月 payDayB = 上月 cutoff+1–月底 工时 × rate + 上月奖惩净额
const payoutThisA = computed(() => Math.max(0, monthStats.value.h1 * cfg.value.rate));
const payoutThisB = computed(() => Math.max(0, prevStats.value.h2 * cfg.value.rate + prevStats.value.adjustNet));
const payoutNextB = computed(() => Math.max(0, monthStats.value.h2 * cfg.value.rate + monthStats.value.adjustNet));''',
''' // 发放口径与两套记账口径一次算齐(细节见 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);'''))
E.append(('earned/carry', ''' const earnedTotal = computed(() => monthStats.value.basePay + monthStats.value.adjustNet);
const cashTotal = computed(() => payoutThisB.value + payoutThisA.value);''',
''' const earnedTotal = computed(() => flows.value.earnedTotal);
const cashTotal = computed(() => flows.value.cashTotal);'''))
E.append(('earnedCarry/split', ''' const earnedCarry = computed(() => payoutNextB.value);
const earnedSplit = computed(() => ({ paid: payoutThisA.value, carry: earnedCarry.value }));''',
''' const earnedCarry = computed(() => flows.value.carry);
const earnedSplit = computed(() => flows.value.split);'''))
E.append(('payDayAPassed', ''' const payDayAPassed = computed(() => {
if (!isCurrentMonth.value) {
const n = new Date();
return currentYear.value < n.getFullYear() ||
(currentYear.value === n.getFullYear() && currentMonth.value < n.getMonth());
}
return new Date().getDate() >= cfg.value.payDayA;
});''', ''' const payDayAPassed = computed(() => P.payDayAPassed(currentYear.value, currentMonth.value, cfg.value));'''))
E.append(('earnedFloored', ''' const earnedFloored = computed(() => monthStats.value.h2 * cfg.value.rate + monthStats.value.adjustNet < 0);''',
''' const earnedFloored = computed(() => flows.value.floored);'''))
E.append(('penaltyDetails', ''' const penaltyDetails = computed(() => {
const c = cfg.value;
const prefix = monthPrefix(currentYear.value, currentMonth.value);
const out = [];
for (const key in records.value) {
if (key.indexOf(prefix) !== 0) continue;
const r = records.value[key];
if (!r || r.status !== 'work') continue;
const missed = Number(r.contraband_missed || 0);
const other = Math.max(0, Number(r.other_penalty || 0));
const amount = missed * c.missedPenalty + other;
if (amount <= 0) continue;
const parts = [];
if (missed > 0) parts.push(`漏查${missed}件`);
if (other > 0) parts.push(r.penalty_reason ? r.penalty_reason : '其他扣款');
out.push({ day: Number(key.slice(prefix.length)), reason: parts.join('·'), amount });
}
return out.sort((a, b) => a.day - b.day);
});''', ''' const penaltyDetails = computed(() => P.penaltyDetails(currentYear.value, currentMonth.value, payrollCtx()));'''))
E.append(('fullDaysLeft', ''' const fullDaysLeft = computed(() => Math.max(0, cfg.value.fullDays - monthStats.value.workDays));''',
''' const fullDaysLeft = computed(() => P.fullDaysLeft(monthStats.value, cfg.value));'''))
E.append(('payDayKind', ''' const payDayKind = (day) => {
const c = cfg.value;
if (day === c.payDayA) return 'a';
if (day === c.payDayB) return 'b';
return null;
};''', ''' const payDayKind = (day) => P.payDayKind(day, cfg.value);'''))
E.append(('isCutEdge', ''' const isCutEdge = (day) => {
const c = cfg.value.cutoff;
return day <= c && day + 7 > c;
};''', ''' const isCutEdge = (day) => P.isCutEdge(day, cfg.value);'''))
E.append(('saveRecord 归一', ''' const op = Number(draftRecord.value.other_penalty);
draftRecord.value.other_penalty = (Number.isFinite(op) && op > 0) ? op : null;
if (draftRecord.value.status !== 'work') {
draftRecord.value.contraband_found = 0;
draftRecord.value.contraband_missed = 0;
draftRecord.value.other_penalty = null;
draftRecord.value.penalty_reason = '';
}
const dayKey''', ''' // 原地归一:非正扣款回落 null;非「上班」日清空全部奖惩字段
P.normalizeDraft(draftRecord.value);
const dayKey'''))
bad = []
for tag, old, new in E:
n = src.count(old)
if n != 1:
bad.append(f'{tag}: 命中 {n} 次(要求恰好 1 次)')
continue
src = src.replace(old, new, 1)
if bad:
print('❌ 未落盘,以下替换不唯一或找不到:')
for b in bad:
print(' ' + b)
sys.exit(1)
io.open(PATH, 'w', encoding='utf-8').write(src)
print(f'✅ {len(E)} 处替换全部命中并落盘')
print(f' {len(orig)} → {len(src)} 字节(净减 {len(orig) - len(src)})')
+1
View File
File diff suppressed because one or more lines are too long
+14
View File
File diff suppressed because one or more lines are too long
+715
View File
@@ -0,0 +1,715 @@
// 工时薪酬助手 —— 核心算法验证
// 做法:mock 极简 Vue(ref/reactive/computed/watch/onMounted)+ localStorage,
// 直接执行 index.html 里真实的 setup(),断言其返回的计算结果。
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const HTML = path.join(__dirname, 'index.html');
const PAYROLL = path.join(__dirname, 'shared', 'payroll.js');
const src = fs.readFileSync(HTML, 'utf-8');
const code = src.match(/<script>\s*([\s\S]*?)\s*<\/script>\s*<\/body>/)[1];
// ---------- 极简 Vue 桩件 ----------
function ref(v) { return { value: v }; }
function reactive(o) { return o; } // 测试只需可读写对象,不需要真响应式
function computed(fn) { return { get value() { return fn(); } }; }
function watch() { /* 持久化副作用与算法无关,空实现 */ }
function onMounted(fn) { onMounted._q.push(fn); }
onMounted._q = [];
let captured = null;
const Vue = {
createApp(opts) { captured = opts.setup(); return { mount() { return captured; } }; },
ref, reactive, computed, watch, onMounted
};
// ---------- 环境桩件 ----------
function makeStore(records, settings, adjust) {
const data = { work_records: JSON.stringify(records) };
if (settings) data.work_settings = JSON.stringify(settings);
if (adjust) data.work_month_adjust = JSON.stringify(adjust);
return {
getItem: (k) => (k in data ? data[k] : null),
setItem: (k, v) => { data[k] = v; },
_dump: () => JSON.parse(data.work_records),
_settings: () => data.work_settings ? JSON.parse(data.work_settings) : null
};
}
// adjust:月度奖惩终稿,key 'YYYY-MM' → { status, finalAmount, note }
// 传入即模拟「组长审核已定稿」,不传即「未定稿,只算记录」。
function load(records, year, month, settings, adjust) {
onMounted._q = [];
const localStorage = makeStore(records, settings, adjust);
const sandbox = {
Vue, localStorage, console, Date, JSON, Math, Number, RegExp,
navigator: {},
document: {
createElement: () => ({ style: {}, select() {}, click() {} }),
body: { appendChild() {}, removeChild() {} },
addEventListener() {}
},
confirm: () => true,
setTimeout: () => 0,
Blob: function () {},
URL: { createObjectURL: () => 'blob:x', revokeObjectURL() {} },
// 云端同步是可选增强:桩件刻意不提供 window.WHTSync,
// 于是测试跑的就是「未登录/纯离线」路径,顺带验证同步缺席时算法完全不受影响。
location: { origin: 'http://test' },
window: { addEventListener() {} },
fetch: () => Promise.reject(new Error('offline in test'))
};
vm.createContext(sandbox);
// 必须先注入薪酬纯模块:index.html 的 setup() 现在直接读 WHTPayroll(2026-09-05 重构)。
// 少了这一步会命中 BOOT_DEPS 缺失分支,去摸 document.getElementById 然后炸在沙箱里 ——
// 报错长得像 DOM 问题,实际是依赖没注入,别被带跑(实测踩过)。
vm.runInContext(fs.readFileSync(PAYROLL, 'utf-8'), sandbox, { filename: 'shared/payroll.js' });
vm.runInContext(code, sandbox);
onMounted._q.forEach(f => f()); // 触发 records / settings 载入
captured.currentYear.value = year;
captured.currentMonth.value = month;
return { app: captured, localStorage };
}
// ---------- 断言工具 ----------
let pass = 0, fail = 0;
const near = (a, b) => Math.abs(Number(a) - Number(b)) < 0.05;
function eq(label, got, want) {
if (near(got, want)) { pass++; console.log(` ✅ ${label} = ${Number(got).toFixed(1)}`); }
else { fail++; console.log(` ❌ ${label}:实得 ${got},应为 ${want}`); }
}
function ok(label, cond, detail) {
if (cond) { pass++; console.log(` ✅ ${label}`); }
else { fail++; console.log(` ❌ ${label}${detail ? ':' + detail : ''}`); }
}
function section(t) { console.log(`\n── ${t}`); }
const W = (h, extra = {}) => Object.assign({
status: 'work', shift_type: 'day', hours: h,
contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: ''
}, extra);
const RATE = 19.5, FULL_DAYS = 27, FOUND = 10, MISSED = 20;
// 定稿快捷构造:FINAL('2026-09', -60) 表示 9 月奖惩已由组长审核定稿为 -60
const FINAL = (monthKey, amount) => ({ [monthKey]: { status: 'final', finalAmount: amount, note: '', finalizedAt: '2026-10-01T09:00:00.000Z' } });
const DRAFT = (monthKey, amount) => ({ [monthKey]: { status: 'draft', finalAmount: amount, note: '', finalizedAt: '' } });
// ==========================================================
section('用例1:基础分段 —— 上半月 2 天、下半月 2 天,无奖惩');
{
const rec = {
'2026-09-03': W(8), '2026-09-14': W(8), // 上半月 16h
'2026-09-20': W(10), '2026-09-28': W(10) // 下半月 20h
};
const { app } = load(rec, 2026, 8); // month=8 → 9月
const s = app.monthStats.value;
eq('上半月工时 h1', s.h1, 16);
eq('下半月工时 h2', s.h2, 20);
eq('总工时', s.totalHours, 36);
eq('出勤天数', s.workDays, 4);
eq('工时收入 basePay', s.basePay, 36 * RATE);
eq('发放净额(无奖惩无满勤)', s.adjustNet, 0);
eq('9月22日到账 = 上半月×19.5', app.payoutThisA.value, 16 * RATE);
eq('10月10日预估 = 下半月×19.5', app.payoutNextB.value, 20 * RATE);
}
section('用例2:15日/16日边界 —— 15日必须算上半月');
{
const rec = { '2026-09-15': W(12), '2026-09-16': W(12) };
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
eq('15日 → h1', s.h1, 12);
eq('16日 → h2', s.h2, 12);
}
section('用例3:夜班跨天归开始日 —— 15日夜班10h 整段进上半月');
{
const rec = { '2026-09-15': W(10, { shift_type: 'night' }) };
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
eq('夜班10h 全归 h1', s.h1, 10);
eq('h2 不沾', s.h2, 0);
eq('夜班班次计数', s.nightShifts, 1);
eq('22日到账含该夜班', app.payoutThisA.value, 10 * RATE);
}
section('用例4:时薪恒定 19.5 —— 满勤不再抬高当月时薪');
{
const rec = {};
for (let d = 1; d <= 27; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(8);
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
const H = 27 * 8;
eq('出勤天数', s.workDays, 27);
eq('总工时', s.totalHours, H);
eq('工时收入按 19.5 计(非 20.5)', s.basePay, H * RATE);
eq('满勤奖 = 总工时 × 1,挂账', s.fullBonus, H);
eq('发放净额 = 满勤奖(奖惩未定稿为0)', s.adjustNet, H);
eq('22日到账不含满勤奖', app.payoutThisA.value, 120 * RATE); // 1–15 共 15 天 ×8
eq('下月10日 = 下半月工时 + 满勤奖', app.payoutNextB.value, 96 * RATE + H); // 16–27 共 12 天 ×8
}
section('用例5:差1天不满勤 —— 满勤奖必须为 0');
{
const rec = {};
for (let d = 1; d <= 26; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(8);
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
eq('出勤天数', s.workDays, 26);
eq('满勤奖 = 0', s.fullBonus, 0);
eq('发放净额 = 0', s.adjustNet, 0);
}
section('用例6:奖惩四项归集 —— 未定稿只作记录,一律不计入金额');
{
const rec = {
'2026-09-05': W(8, { contraband_found: 3 }), // +30
'2026-09-20': W(8, { contraband_missed: 2 }), // -40
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' }) // -50
};
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
eq('查获件数', s.foundCount, 3);
eq('查获奖励', s.foundBonus, 30);
eq('漏查件数', s.missedCount, 2);
eq('漏查处罚', s.missedPenalty, 40);
eq('其他扣款', s.otherPenalties, 50);
eq('满勤奖(3天不满勤)', s.fullBonus, 0);
// 与「满勤未达标 → 满勤奖按 0」同一个道理:奖惩未经组长审核定稿 → 一律按 0
eq('日常累计(仅记录)= 30-40-50', s.rawReview, -60);
eq('未定稿奖惩不计入', s.reviewNet, 0);
eq('发放净额 = 0', s.adjustNet, 0);
eq('工时收入不受奖惩影响', s.basePay, 24 * RATE);
eq('22日到账为纯工时(8h)', app.payoutThisA.value, 8 * RATE);
}
section('用例6b:同一份记录 —— 组长定稿 -60 后才计入');
{
const rec = {
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
};
const { app } = load(rec, 2026, 8, null, FINAL('2026-09', -60));
const s = app.monthStats.value;
ok('已定稿标记为真', s.finalized === true);
eq('日常累计不变', s.rawReview, -60);
eq('定稿后奖惩计入 = -60', s.reviewNet, -60);
eq('与累计一致 → 差额 0', s.reviewDelta, 0);
eq('挣得含定稿奖惩', app.earnedTotal.value, 24 * RATE - 60);
}
section('用例6c:草稿不生效 —— 只有 final 才算定稿');
{
const rec = { '2026-09-05': W(8, { contraband_found: 3 }) };
const { app } = load(rec, 2026, 8, null, DRAFT('2026-09', 30));
const s = app.monthStats.value;
ok('草稿状态 finalized 为假', s.finalized === false);
ok('adjustStatus 记为 draft', s.adjustStatus === 'draft', String(s.adjustStatus));
eq('草稿金额不计入', s.reviewNet, 0);
eq('挣得只含工时', app.earnedTotal.value, 8 * RATE);
}
section('用例6d:审核改数 —— 累计 +30,只认 20');
{
const rec = { '2026-09-05': W(8, { contraband_found: 3 }) }; // 累计 +30
const { app } = load(rec, 2026, 8, null, FINAL('2026-09', 20));
const s = app.monthStats.value;
eq('累计 +30', s.rawReview, 30);
eq('终稿只认 +20', s.reviewNet, 20);
eq('差额下调 10', s.reviewDelta, -10);
eq('挣得按终稿', app.earnedTotal.value, 8 * RATE + 20);
}
section('用例7:跨月结转 —— 9月10日 = 8月下半月 +(已定稿的)8月奖惩');
{
const rec = {
// 8月:上半月 8h,下半月 30h,查获5件(+50),漏查1件(-20)
'2026-08-10': W(8),
'2026-08-18': W(10), '2026-08-22': W(10), '2026-08-27': W(10, { contraband_found: 5, contraband_missed: 1 }),
// 9月:上半月 16h
'2026-09-02': W(8), '2026-09-09': W(8)
};
// 未定稿:8月奖惩只作记录,9月10日那笔只发下半月工时
{
const { app } = load(rec, 2026, 8);
const p = app.prevStats.value;
eq('8月下半月工时', p.h2, 30);
eq('8月日常累计(仅记录)= 50-20', p.rawReview, 30);
eq('8月未定稿 → 发放净额 0', p.adjustNet, 0);
eq('9月10日(未定稿)= 30×19.5', app.payoutThisB.value, 30 * RATE);
eq('9月22日 = 16×19.5', app.payoutThisA.value, 16 * RATE);
}
// 已定稿 +30:9月10日那笔才带上奖惩
{
const { app } = load(rec, 2026, 8, null, FINAL('2026-08', 30));
const p = app.prevStats.value;
eq('8月定稿后发放净额 = 30', p.adjustNet, 30);
eq('9月10日 = 30×19.5 + 30', app.payoutThisB.value, 30 * RATE + 30);
eq('9月现金流合计', app.payoutThisB.value + app.payoutThisA.value, 30 * RATE + 30 + 16 * RATE);
}
}
section('用例8:跨年边界 —— 1月10日应取上一年12月');
{
const rec = {
'2025-12-20': W(10), '2025-12-28': W(10, { contraband_found: 2 }), // 下半月20h,+20
'2026-01-05': W(8)
};
const { app } = load(rec, 2026, 0); // 2026年1月
eq('上月标签年份 = 2025', app.prevLabel.value.year, 2025);
eq('上月标签月份 index = 11', app.prevLabel.value.month, 11);
eq('下月标签年份 = 2026', app.nextLabel.value.year, 2026);
eq('下月标签月份 index = 1', app.nextLabel.value.month, 1);
eq('1月10日(12月奖惩未定稿)= 20×19.5', app.payoutThisB.value, 20 * RATE);
// 跨年 key 必须是 '2025-12',定稿后才带上 +20
const f = load(rec, 2026, 0, null, FINAL('2025-12', 20));
eq('12月定稿后 1月10日 = 20×19.5 + 20', f.app.payoutThisB.value, 20 * RATE + 20);
}
section('用例9:changeMonth 跨年翻页 + 回今天');
{
const { app } = load({}, 2026, 0);
app.changeMonth(-1);
eq('1月往前 → 年份 2025', app.currentYear.value, 2025);
eq('1月往前 → 月份 index 11', app.currentMonth.value, 11);
app.changeMonth(1);
eq('12月往后 → 年份 2026', app.currentYear.value, 2026);
eq('12月往后 → 月份 index 0', app.currentMonth.value, 0);
app.goToday();
const n = new Date();
eq('回今天 → 年份', app.currentYear.value, n.getFullYear());
eq('回今天 → 月份', app.currentMonth.value, n.getMonth());
ok('回今天后 isCurrentMonth 为真', app.isCurrentMonth.value === true);
}
section('用例10:处罚超过下半月工资 —— 定稿后发放兜底 0,累计保留负数');
{
const rec = {
'2026-09-20': W(6, { contraband_missed: 10, other_penalty: 200 }) // 下半月6h=117元;罚 200+200=400
};
// 未定稿:处罚也只是记录,不能提前吃掉工时收入
{
const { app } = load(rec, 2026, 8);
eq('日常累计保留真实负数', app.monthStats.value.rawReview, -400);
eq('未定稿不扣发放', app.monthStats.value.adjustNet, 0);
eq('下月10日 = 下半月工时', app.payoutNextB.value, 6 * RATE);
}
// 定稿 -400:才真正扣,且兜底 0
{
const { app } = load(rec, 2026, 8, null, FINAL('2026-09', -400));
eq('定稿后发放净额 = -400', app.monthStats.value.adjustNet, -400);
eq('下月10日发放兜底为 0', app.payoutNextB.value, 0);
}
}
section('用例11:休息/请假不计入工时与出勤,但单独计数');
{
const rec = {
'2026-09-03': W(8),
'2026-09-04': { status: 'rest' },
'2026-09-05': { status: 'leave' },
'2026-09-06': { status: 'rest' }
};
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
eq('出勤天数只算 work', s.workDays, 1);
eq('总工时只算 work', s.totalHours, 8);
eq('休息天数', s.restDays, 2);
eq('请假天数', s.leaveDays, 1);
}
section('用例12:其他扣款负数被夹为 0(脏数据防御)');
{
const rec = { '2026-09-20': W(8, { other_penalty: -500 }) };
const { app } = load(rec, 2026, 8);
eq('负数扣款不变成奖励', app.monthStats.value.otherPenalties, 0);
eq('奖惩净额 = 0', app.monthStats.value.adjustNet, 0);
}
section('用例13:saveRecord —— 非正扣款归一为 null;非上班清空奖惩');
{
const { app, localStorage } = load({}, 2026, 8);
app.selectedDay.value = 20;
app.draftRecord.value = W(8, { other_penalty: -30 });
app.saveRecord();
ok('负扣款落库为 null', localStorage._dump()['2026-09-20'].other_penalty === null);
app.draftRecord.value = W(8, { other_penalty: 60 });
app.saveRecord();
eq('正扣款正常落库', localStorage._dump()['2026-09-20'].other_penalty, 60);
app.selectedDay.value = 21;
app.draftRecord.value = W(8, { status: 'rest', contraband_found: 5, contraband_missed: 3, other_penalty: 99 });
app.saveRecord();
const r = localStorage._dump()['2026-09-21'];
ok('休息日清空查获', r.contraband_found === 0);
ok('休息日清空漏查', r.contraband_missed === 0);
ok('休息日清空扣款', r.other_penalty === null);
app.selectedDay.value = 20;
app.deleteRecord();
ok('deleteRecord 移除该日', localStorage._dump()['2026-09-20'] === undefined);
}
section('用例14:月份隔离 —— 只聚合本月,不串月');
{
const rec = { '2026-08-05': W(12), '2026-10-05': W(12), '2026-09-05': W(8) };
const { app } = load(rec, 2026, 8);
eq('9月总工时不含8/10月', app.monthStats.value.totalHours, 8);
}
section('用例15:旧版记录兼容 —— 字段结构未变,直接可读');
{
const legacy = {
'2026-09-08': { status: 'work', shift_type: 'night', hours: 10, contraband_found: 1, contraband_missed: 0, other_penalty: null, penalty_reason: '' }
};
const { app } = load(legacy, 2026, 8);
const s = app.monthStats.value;
eq('旧记录工时可读', s.totalHours, 10);
eq('旧记录查获奖励可读', s.foundBonus, FOUND);
eq('旧记录归上半月', s.h1, 10);
}
section('用例16:设置可改写规则 —— 时薪/满勤/单价/截止日');
{
const rec = {};
for (let d = 1; d <= 20; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(10);
// 自定义:时薪 30、满勤 20 天、满勤奖 2 元/h、截止日 10、发放日 25/5
const custom = { rate: 30, fullDays: 20, fullBonusPerHour: 2, cutoff: 10, payDayA: 25, payDayB: 5 };
const { app } = load(rec, 2026, 8, custom);
const s = app.monthStats.value;
eq('自定义截止日 → 上半月 10 天', s.h1, 100);
eq('自定义截止日 → 下半月 10 天', s.h2, 100);
eq('自定义时薪生效', s.basePay, 200 * 30);
eq('自定义满勤门槛达成 → 满勤奖 = 200h × 2', s.fullBonus, 400);
eq('25号到账 = 100h × 30', app.payoutThisA.value, 100 * 30);
eq('次月5号 = 100h × 30 + 400', app.payoutNextB.value, 100 * 30 + 400);
}
section('用例17:设置为空/非法时回落默认,不产生 NaN');
{
const rec = { '2026-09-05': W(8) };
const bad = { rate: null, fullDays: 0, fullBonusPerHour: undefined, cutoff: 99, foundBonus: -5, missedPenalty: NaN };
const { app } = load(rec, 2026, 8, bad);
const c = app.cfg.value;
eq('时薪回落 19.5', c.rate, RATE);
eq('满勤天数回落 27', c.fullDays, FULL_DAYS);
eq('满勤单价回落 1', c.fullBonusPerHour, 1);
eq('截止日夹到 28', c.cutoff, 28);
eq('查获单价回落 10', c.foundBonus, FOUND);
eq('漏查单价回落 20', c.missedPenalty, MISSED);
ok('basePay 不是 NaN', Number.isFinite(app.monthStats.value.basePay), String(app.monthStats.value.basePay));
ok('adjustNet 不是 NaN', Number.isFinite(app.monthStats.value.adjustNet));
}
section('用例18:处罚明细列表 —— 按日排序并含原因');
{
const rec = {
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' }),
'2026-09-08': W(8, { contraband_missed: 2 }),
'2026-09-12': W(8, { contraband_missed: 1, other_penalty: 30 }),
'2026-09-15': W(8) // 无处罚,不应出现
};
const { app } = load(rec, 2026, 8);
const d = app.penaltyDetails.value;
eq('明细条数', d.length, 3);
eq('首条按日排序 = 8日', d[0].day, 8);
eq('8日金额 = 2×20', d[0].amount, 40);
ok('8日原因含漏查', d[0].reason.indexOf('漏查2件') >= 0, d[0].reason);
eq('12日金额 = 20+30', d[1].amount, 50);
eq('25日金额 = 50', d[2].amount, 50);
ok('25日原因取备注', d[2].reason.indexOf('迟到') >= 0, d[2].reason);
}
section('用例19:日历格子样式 —— 状态底色与半月分界虚线');
{
const rec = {
'2026-09-05': W(8),
'2026-09-12': W(10, { shift_type: 'night' }),
'2026-09-20': W(8, { contraband_missed: 1 }),
'2026-09-06': { status: 'rest' },
'2026-09-07': { status: 'leave' }
};
const { app } = load(rec, 2026, 8);
const j = (day) => app.cellClass(day).join(' ');
// 白班走暖橙、夜班走靛蓝,班次一眼可辨
ok('白班日暖橙底', j(5).indexOf('bg-amber-50/70') >= 0, j(5));
ok('夜班日靛蓝底', j(12).indexOf('bg-indigo-50/70') >= 0, j(12));
ok('休息日灰底', j(6).indexOf('bg-slate-100/70') >= 0);
ok('请假日晨光蓝底', j(7).indexOf('bg-sky-50/60') >= 0);
ok('未记录日白底', j(28).indexOf('bg-white') >= 0);
// 分界虚线:7 列网格里 cutoff-6 … cutoff 这一整周加底虚线(默认 cutoff=15 → 9–15 日)
ok('9 日属分界行', app.isCutEdge(9) === true);
ok('15 日(cutoff)属分界行', app.isCutEdge(15) === true);
ok('8 日不属分界行', app.isCutEdge(8) === false);
ok('16 日(下半月)不属分界行', app.isCutEdge(16) === false);
ok('分界格带 cut-edge 类', j(15).indexOf('cut-edge') >= 0, j(15));
ok('非分界格不带 cut-edge 类', j(5).indexOf('cut-edge') < 0, j(5));
ok('分界行刚好 7 格', [...Array(31).keys()].map(i => i + 1).filter(d => app.isCutEdge(d)).length === 7);
ok('20日有处罚标记', app.hasPenalty(20) === true);
ok('5日无处罚标记', app.hasPenalty(5) === false);
}
section('用例20:页签与文本报告');
{
const rec = { '2026-09-05': W(8), '2026-09-20': W(10, { contraband_found: 1 }) };
const { app } = load(rec, 2026, 8);
eq('默认落在打卡页', app.tab.value === 'calendar' ? 1 : 0, 1);
eq('页签数量 = 3', app.tabs.length, 3);
ok('页签顺序为 打卡/汇总/设置', app.tabs.map(t => t.id).join(',') === 'calendar,summary,settings', app.tabs.map(t => t.id).join(','));
const rpt = app.buildReport();
ok('报告含时薪 19.5', rpt.indexOf('19.5') >= 0);
ok('报告含奖惩结算说明', rpt.indexOf('次月10号结算') >= 0);
ok('报告含两笔到账', rpt.indexOf('9月22日') >= 0 && rpt.indexOf('9月10日') >= 0);
ok('报告含次月预估', rpt.indexOf('10月10日预估') >= 0);
eq('记录计数', app.recordCount.value, 2);
}
section('用例21:导入 —— 合并记录并跳过非法键');
{
const { app } = load({ '2026-09-01': W(8) }, 2026, 8);
const n = app.applyImport({
records: {
'2026-09-02': W(10),
'2026-09-03': W(6),
'not-a-date': W(99),
'settings': W(99)
},
settings: { rate: 25 }
});
eq('导入计数只算合法日期', n, 2);
eq('导入后记录总数', app.recordCount.value, 3);
eq('导入携带的设置生效', app.cfg.value.rate, 25);
eq('导入后 9月总工时 = 8+10+6', app.monthStats.value.totalHours, 24);
}
section('用例22:清空 —— 按月清与全清');
{
const rec = { '2026-08-05': W(8), '2026-09-05': W(8), '2026-09-20': W(8) };
const { app, localStorage } = load(rec, 2026, 8);
app.clearMonth();
eq('清空9月后剩余记录数', app.recordCount.value, 1);
ok('8月记录仍在', localStorage._dump()['2026-08-05'] !== undefined);
app.clearAll();
eq('全清后记录数', app.recordCount.value, 0);
}
section('用例23:resetSettings 恢复默认');
{
const { app } = load({}, 2026, 8, { rate: 99, fullDays: 5, cutoff: 3 });
eq('自定义时薪已加载', app.cfg.value.rate, 99);
app.resetSettings();
eq('恢复后时薪', app.cfg.value.rate, RATE);
eq('恢复后满勤天数', app.cfg.value.fullDays, FULL_DAYS);
eq('恢复后截止日', app.cfg.value.cutoff, 15);
}
section('用例24:班次默认工时可配置');
{
const { app } = load({}, 2026, 8, { dayHours: 7.5, nightHours: 11.5 });
app.openDayDrawer(10);
eq('新建默认取白班工时', app.draftRecord.value.hours, 7.5);
app.selectShift('night');
eq('切夜班取夜班工时', app.draftRecord.value.hours, 11.5);
app.selectShift('day');
eq('切回白班', app.draftRecord.value.hours, 7.5);
}
section('用例25:空数据不崩');
{
const { app } = load({}, 2026, 8);
const s = app.monthStats.value;
eq('总工时 0', s.totalHours, 0);
eq('工时收入 0', s.basePay, 0);
eq('奖惩净额 0', s.adjustNet, 0);
eq('本月10日 0', app.payoutThisB.value, 0);
eq('本月22日 0', app.payoutThisA.value, 0);
eq('次月10日 0', app.payoutNextB.value, 0);
eq('处罚明细为空', app.penaltyDetails.value.length, 0);
}
section('用例26:结算工时选项 6–12 / 0.5 步进,共 13 项');
{
const { app } = load({}, 2026, 8);
eq('选项个数', app.hourOptions.length, 13);
eq('首项 = 6', app.hourOptions[0], 6);
eq('末项 = 12', app.hourOptions[12], 12);
ok('完整序列 6,6.5,...,12',
app.hourOptions.join(',') === '6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12',
app.hourOptions.join(','));
ok('无浮点误差(全部为 .0 或 .5)',
app.hourOptions.every(h => h * 2 === Math.round(h * 2)),
app.hourOptions.join(','));
}
section('用例27:hInt / hFrac 对齐拆分(日历格子整数与 .5 不错位)');
{
const { app } = load({}, 2026, 8);
eq('8 的整数部', app.hInt(8), 8);
ok('8 的小数部为空', app.hFrac(8) === '', JSON.stringify(app.hFrac(8)));
eq('8.5 的整数部', app.hInt(8.5), 8);
ok('8.5 的小数部为 .5', app.hFrac(8.5) === '.5', app.hFrac(8.5));
eq('12 的整数部', app.hInt(12), 12);
ok('12 的小数部为空', app.hFrac(12) === '');
ok('拼回原值 6', app.hInt(6) + app.hFrac(6) === '6', app.hInt(6) + app.hFrac(6));
ok('拼回原值 11.5', app.hInt(11.5) + app.hFrac(11.5) === '11.5', app.hInt(11.5) + app.hFrac(11.5));
ok('每个选项拆分后都能拼回原值',
app.hourOptions.every(h => Number(app.hInt(h) + (app.hFrac(h) || '')) === h));
}
section('用例28:已保存记录不被静默篡改(包括越界旧值)');
{
const rec = {
'2026-09-05': W(14), // 旧数据,超出 6–12
'2026-09-06': W(4),
'2026-09-07': W(8.5)
};
const { app, localStorage } = load(rec, 2026, 8);
eq('聚合按原值(14+4+8.5)', app.monthStats.value.totalHours, 26.5);
app.openDayDrawer(5);
eq('打开编辑器也不改旧值', app.draftRecord.value.hours, 14);
app.openDayDrawer(7);
eq('合法 8.5 不变', app.draftRecord.value.hours, 8.5);
app.selectedDay.value = 20;
app.draftRecord.value = W(8.5);
app.saveRecord();
eq('选单保存 8.5 原值落库', localStorage._dump()['2026-09-20'].hours, 8.5);
}
section('用例29:班次默认工时落入 6–12 可选范围');
{
const { app } = load({}, 2026, 8, { dayHours: 2, nightHours: 20 });
eq('白班 2h 夹到 6', app.cfg.value.dayHours, 6);
eq('夜班 20h 夹到 12', app.cfg.value.nightHours, 12);
app.openDayDrawer(10);
eq('新建取夹后白班值', app.draftRecord.value.hours, 6);
app.selectShift('night');
eq('切夜班取夹后值', app.draftRecord.value.hours, 12);
ok('两个默认值都在选项里',
app.hourOptions.indexOf(app.cfg.value.dayHours) >= 0 && app.hourOptions.indexOf(app.cfg.value.nightHours) >= 0);
}
// ==========================================================
section('用例30:统一口径 —— 挣得 = 工时 + 满勤奖(达标才计) + 奖惩净额');
{
// 用户核对场景:9月 3天22h(8+8+6),其中1天查获1件
const rec = {
'2026-09-01': W(8),
'2026-09-02': W(8),
'2026-09-03': W(6, { contraband_found: 1 })
};
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
eq('总工时', s.totalHours, 22);
eq('工时收入 basePay', s.basePay, 22 * RATE); // 429
eq('满勤奖未达27天不计', s.fullBonus, 0);
// 与满勤同一个道理:查获 1 件只是记录,未经组长审核定稿前不计入金额
eq('日常累计(仅记录)= +10', s.rawReview, FOUND);
eq('未定稿不计入应得', s.reviewNet, 0);
// 决策1:打卡页大字 == 汇总页合计应得 == 429 + 0 + 0 = 429
eq('统一挣得 earnedTotal(未定稿不计)', app.earnedTotal.value, 22 * RATE);
eq('打卡页大字与之同源', Number(String(app.boardMain.value.int).replace(/,/g, '')), 429);
eq('距满勤天数', app.fullDaysLeft.value, FULL_DAYS - 3);
// 决策2:结转 == 次月10号实际到账(含满勤奖,此处满勤为0)
eq('结转 = payoutNextB', app.earnedSplit.value.carry, app.payoutNextB.value);
eq('结转值(下半月0h、奖惩未定稿)', app.earnedSplit.value.carry, 0);
}
section('用例30b:同一场景 —— 组长定稿 +10 后才变 439');
{
const rec = {
'2026-09-01': W(8),
'2026-09-02': W(8),
'2026-09-03': W(6, { contraband_found: 1 })
};
const { app } = load(rec, 2026, 8, null, FINAL('2026-09', FOUND));
const s = app.monthStats.value;
eq('定稿后奖惩计入 = +10', s.reviewNet, FOUND);
eq('挣得 = 429 + 0 + 10', app.earnedTotal.value, 22 * RATE + FOUND);
eq('打卡页大字 = 439', Number(String(app.boardMain.value.int).replace(/,/g, '')), 439);
eq('结转 = payoutNextB', app.earnedSplit.value.carry, app.payoutNextB.value);
eq('结转值(下半月0h + 奖惩10)', app.earnedSplit.value.carry, FOUND);
eq('三项相加 = 大字', s.basePay + s.fullBonus + s.reviewNet, app.earnedTotal.value);
}
section('用例31:满勤达标 —— 满勤奖同时进「挣得」与「结转」,两处不再打架');
{
const rec = {};
for (let d = 1; d <= 27; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(8);
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
const H = 27 * 8; // 216h
eq('出勤天数达标', s.workDays, 27);
eq('满勤奖 = 总工时×1', s.fullBonus, H);
eq('挣得 = 工时 + 满勤奖', app.earnedTotal.value, H * RATE + H);
eq('打卡页大字同值', Number(String(app.boardMain.value.int).replace(/,/g, '')), Math.floor(H * RATE + H));
eq('距满勤 0 天', app.fullDaysLeft.value, 0);
// 结转必须含满勤奖,且与次月实际到账严格相等
eq('结转 = payoutNextB', app.earnedSplit.value.carry, app.payoutNextB.value);
eq('结转 = 下半月工时 + 满勤奖', app.earnedSplit.value.carry, 96 * RATE + H); // 16–27 共12天×8=96h
// 三项相加严丝合缝等于大字
eq('工时+满勤+奖惩 = 挣得', s.basePay + s.fullBonus + s.reviewNet, app.earnedTotal.value);
}
section('用例32:漏查处罚 —— 未定稿不扣,定稿后才扣');
{
const rec = {
'2026-09-10': W(8, { contraband_found: 2 }), // +20
'2026-09-20': W(8, { contraband_missed: 3 }), // -60
'2026-09-21': W(8, { other_penalty: 15 }) // -15
};
{
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
eq('日常累计 = 20-60-15', s.rawReview, 20 - 60 - 15);
eq('未定稿不计入', s.reviewNet, 0);
eq('挣得只含工时', app.earnedTotal.value, 24 * RATE);
eq('结转 = payoutNextB', app.earnedSplit.value.carry, app.payoutNextB.value);
}
{
const { app } = load(rec, 2026, 8, null, FINAL('2026-09', -55));
const s = app.monthStats.value;
eq('定稿后计入 = -55', s.reviewNet, -55);
eq('挣得含负奖惩', app.earnedTotal.value, 24 * RATE + (20 - 60 - 15));
eq('结转 = payoutNextB', app.earnedSplit.value.carry, app.payoutNextB.value);
}
}
section('用例33:奖惩与满勤同口径 —— 未定稿一律不进任何金额口径');
{
// 27 天满勤 + 查获 5 件(未定稿):满勤奖计,奖惩不计
const rec = {};
for (let d = 1; d <= 27; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(8);
rec['2026-09-05'] = W(8, { contraband_found: 5 });
const H = 27 * 8;
{
const { app } = load(rec, 2026, 8);
const s = app.monthStats.value;
eq('满勤达标 → 满勤奖计入', s.fullBonus, H);
eq('查获累计 +50 仅记录', s.rawReview, 50);
eq('未定稿 → 奖惩不计', s.reviewNet, 0);
eq('挣得 = 工时 + 满勤', app.earnedTotal.value, H * RATE + H);
eq('三项相加 = 大字', s.basePay + s.fullBonus + s.reviewNet, app.earnedTotal.value);
// 未定稿时尾行合计只剩满勤奖
eq('计入应得合计 = 仅满勤奖', s.adjustNet, H);
}
{
const { app } = load(rec, 2026, 8, null, FINAL('2026-09', 50));
const s = app.monthStats.value;
eq('定稿后三项全计', app.earnedTotal.value, H * RATE + H + 50);
eq('三项相加仍等大字', s.basePay + s.fullBonus + s.reviewNet, app.earnedTotal.value);
// 奖惩卡尾行「计入应得合计」= 满勤奖 + 奖惩净额,也就是 adjustNet
eq('计入应得合计 = 满勤 + 奖惩', s.adjustNet, s.fullBonus + s.reviewNet);
eq('计入应得合计值', s.adjustNet, H + 50);
eq('结转 = payoutNextB', app.earnedSplit.value.carry, app.payoutNextB.value);
eq('结转 = 下半月工时 + 满勤 + 奖惩', app.earnedSplit.value.carry, 96 * RATE + H + 50);
}
}
// ==========================================================
console.log(`\n${'='.repeat(46)}`);
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
console.log('='.repeat(46));
process.exit(fail === 0 ? 0 : 1);