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