- 多用户支持:新增 user_mcp_tokens 表,实现按用户 Token 哈希隔离 MCP 查询 - 注册控制:支持 ALLOW_REGISTER 环境变量自适应开关多用户注册 - 前端同步:增加跨账号防脏提交安全拦截,优化登录/注册状态提示 - 汇总页优化:移除合计应得卡片冗余说明,诊断卡片新增「综合时薪诊断」 - 文档更新:同步更新 README.md 关于多用户、MCP 与部署说明
535 lines
21 KiB
JavaScript
535 lines
21 KiB
JavaScript
/**
|
||
* 客户端同步引擎(离线优先)
|
||
*
|
||
* 设计原则:
|
||
* 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));
|
||
}
|
||
}
|
||
})();
|