Files
work-hours-tracker/sync.js
T
OpenClaw Agent 53733b39fc feat: 支持多用户与用户级MCP隔离,优化汇总诊断与UI展示
- 多用户支持:新增 user_mcp_tokens 表,实现按用户 Token 哈希隔离 MCP 查询
- 注册控制:支持 ALLOW_REGISTER 环境变量自适应开关多用户注册
- 前端同步:增加跨账号防脏提交安全拦截,优化登录/注册状态提示
- 汇总页优化:移除合计应得卡片冗余说明,诊断卡片新增「综合时薪诊断」
- 文档更新:同步更新 README.md 关于多用户、MCP 与部署说明
2026-09-14 14:00:07 +08:00

535 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 客户端同步引擎(离线优先)
*
* 设计原则:
* 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',
lastUser: 'work_sync_last_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 探测到的服务端用户数
allowRegister: null, // 是否开放注册
lastUser: readLS(LS.lastUser, null)
};
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;
state.allowRegister = !!h.allowRegister;
emit();
return h;
} catch (e) {
state.serverUsers = null;
state.allowRegister = 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;
state.lastUser = r.user;
writeLS(LS.token, r.token);
writeLS(LS.user, r.user);
writeLS(LS.lastUser, 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);
localStorage.removeItem(LS.dirty);
localStorage.removeItem(LS.lastUser);
dirty.records.clear(); dirty.adjust.clear(); dirty.settings = false;
state.pending = 0;
state.lastUser = null;
}
setStatus('idle');
},
/** 清空本地打卡缓存与脏数据(用于切换账号时的安全重置) */
clearLocalCache() {
localStorage.removeItem(LS.records);
localStorage.removeItem(LS.adjust);
localStorage.removeItem(LS.dirty);
localStorage.removeItem(LS.rev);
localStorage.removeItem(LS.hold);
dirty.records.clear(); dirty.adjust.clear(); dirty.settings = false;
state.pending = 0;
state.rev = 0;
state.hold = null;
emit();
},
getLastUser() {
return readLS(LS.lastUser, null) || state.lastUser;
},
/** 用服务器数据整体覆盖本地(换设备后的首次拉取) */
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));
}
}
})();