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
+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); });