diff --git a/ui-html/webview2/settings/ime-settings/src/modules/stats.ts b/ui-html/webview2/settings/ime-settings/src/modules/stats.ts
index eefc0a9e..41e3e03c 100644
--- a/ui-html/webview2/settings/ime-settings/src/modules/stats.ts
+++ b/ui-html/webview2/settings/ime-settings/src/modules/stats.ts
@@ -28,14 +28,23 @@ const CALENDAR_MAX_WEEKS = 200;
/** 按日明细只展示最近 30 天(overview.daily 是全量历史)。 */
const DETAIL_DAYS = 30;
const STATUS_TIMEOUT_MS = 4000;
+/** 统计页可见时的轮询间隔:一次查询只是 Server 端的一次 SQLite 只读快照。 */
+const OVERVIEW_POLL_MS = 5000;
+/** 焦点、可见性、轮询可能同时触发,这个间隔内只真正发一次查询。 */
+const OVERVIEW_MIN_INTERVAL_MS = 1000;
+/** 在途概览查询超过这个时间没回来,就当它丢了,允许再发。 */
+const OVERVIEW_STALE_MS = 15000;
let statisticsEnabled = false;
let lastOverview: StatsOverview | null = null;
+let lastOverviewSignature = '';
let calendarWeeks = CALENDAR_MIN_WEEKS;
let calendarCellPx = CALENDAR_CELL_PX;
let calendarObserver: ResizeObserver | null = null;
let requestCounter = 0;
let statusTimer: number | null = null;
+let lastOverviewRequestAt = 0;
+let overviewPollTimer: number | null = null;
const pendingRequests = new Map();
// ------------------------------------------------------------------ 纯函数(单测覆盖)
@@ -203,19 +212,33 @@ function renderEmptyState(): void {
}
function renderOverview(overview: StatsOverview): void {
+ // 轮询每隔几秒就回来一份概览,没打字时内容完全一样。各 render 都是整棵子树
+ // replaceChildren,会抹掉热力图的横向滚动和正在看的 tooltip,所以数据没变就不重绘。
+ const signature = JSON.stringify(overview);
+ const unchanged = signature === lastOverviewSignature && lastOverview !== null;
+ lastOverviewSignature = signature;
lastOverview = overview;
+ if (unchanged) {
+ return;
+ }
renderEmptyState();
const hasData = overview.hasData;
setHidden('statsContent', !hasData);
if (!hasData) {
return;
}
+ // 重建热力图会把滚动位置打回 0,列数没变时原样放回去。
+ const calendarBox = byId('statsCalendar');
+ const calendarScroll = calendarBox?.scrollLeft ?? 0;
renderCards(overview);
renderSpeed(overview);
renderCalendar(overview);
renderHourly(overview);
renderCategories(overview);
renderDetails(overview);
+ if (calendarBox) {
+ calendarBox.scrollLeft = calendarScroll;
+ }
}
function statCard(title: string, value: string, unit: string, note: string): HTMLElement {
@@ -429,6 +452,18 @@ function setClearStatus(message: string, isError: boolean): void {
function post(action: StatsRequest['action']): void {
const requestId = `stats-${++requestCounter}`;
+ if (action === 'overview') {
+ // 所有概览查询(含 setup 的首次请求、清空后的回填)都记时间,
+ // refreshOverview 的节流才不会在刚查完之后又补一次。
+ lastOverviewRequestAt = Date.now();
+ // 在途的旧概览请求丢了响应就再也不会销账,这里顺手清掉,免得 map 越积越长;
+ // 迟到的响应会因为查不到 action 被 handleResponse 忽略。
+ for (const [id, pending] of pendingRequests) {
+ if (pending === 'overview') {
+ pendingRequests.delete(id);
+ }
+ }
+ }
pendingRequests.set(requestId, action);
window.chrome?.webview?.postMessage(serializeHostMessage({ type: 'statsRequest', data: { requestId, action } }));
}
@@ -514,31 +549,76 @@ function setupCalendarResize(): void {
applyMetrics();
}
+/** 统计页当前是不是真的在用户眼前:窗口没被隐藏,且侧栏停在统计模块上。 */
+function isStatsPageVisible(): boolean {
+ if (document.visibilityState !== 'visible') {
+ return false;
+ }
+ const panel = byId('stats');
+ // offsetParent 为空说明自己或祖先 display:none,覆盖 showOnlyCurrentModule
+ // 之外的隐藏路径;空面板(partial 还没插入)也会被挡掉。
+ return !!panel && panel.offsetParent !== null;
+}
+
+/** 同一时刻只留一个在途概览查询,Server 慢时不至于堆成一串。 */
+function hasPendingOverview(): boolean {
+ // 响应只在 statsResponse 里销账:万一丢了一条,不能让刷新从此永久停摆,
+ // 超过这个时间的在途请求一律当作没有。
+ if (Date.now() - lastOverviewRequestAt > OVERVIEW_STALE_MS) {
+ return false;
+ }
+ for (const action of pendingRequests.values()) {
+ if (action === 'overview') {
+ return true;
+ }
+ }
+ return false;
+}
+
+function refreshOverview(): void {
+ if (!isStatsPageVisible() || hasPendingOverview()) {
+ return;
+ }
+ if (Date.now() - lastOverviewRequestAt < OVERVIEW_MIN_INTERVAL_MS) {
+ return;
+ }
+ post('overview');
+}
+
/**
- * 统计不是推送模型,只在「重新看到统计页」时补一次查询。设置窗口关闭只是隐藏
- * (文档与已加载的模块都保留),侧栏切走再切回也不会重跑 setup,所以这两种路径
- * 都要在这里刷新,否则用户打开开关、打字、再回来看不到新计数。
+ * 统计不是推送模型(数据由 Server 进程写库,设置窗口是另一个进程,自己开库查),
+ * 所以「什么时候补一次查询」全靠这里的几个触发源:
+ * - 侧栏切走再切回:模块不会重跑 setup,只靠 IntersectionObserver 的显隐翻转;
+ * - 设置窗口关掉再打开:只是隐藏 + controller.put_IsVisible(false),走 visibilitychange;
+ * - 窗口一直开着、用户切到别的程序打字再点回来:上面两个都不会触发,只有 focus;
+ * - 人就盯着统计页、在旁边的窗口里打字:连 focus 都没有,靠可见时的轮询兜底。
+ * 都汇到 refreshOverview,由它做节流和在途去重,避免几个触发源叠在一起连发。
*/
function setupOverviewRefresh(): void {
const root = byId('stats-settings');
if (root && typeof IntersectionObserver !== 'undefined') {
let visible = false;
- let firstShow = true;
new IntersectionObserver((entries) => {
const nowVisible = entries.some((entry) => entry.isIntersecting);
+ // 首次显示紧跟 setupStats 的初始请求,被 refreshOverview 的节流挡掉。
if (nowVisible && !visible) {
- // 首次显示紧跟 setupStats 的初始请求,不重复查询。
- if (!firstShow) post('overview');
- firstShow = false;
+ refreshOverview();
}
visible = nowVisible;
}).observe(root);
}
document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible' && byId('stats')?.style.display !== 'none') {
- post('overview');
- }
+ refreshOverview();
});
+ window.addEventListener('focus', () => {
+ refreshOverview();
+ });
+ if (overviewPollTimer !== null) {
+ window.clearInterval(overviewPollTimer);
+ }
+ // 常驻定时器:不可见时 refreshOverview 直接返回,窗口隐藏后浏览器还会自己降频,
+ // 比按显隐反复建销定时器少一份状态。
+ overviewPollTimer = window.setInterval(refreshOverview, OVERVIEW_POLL_MS);
}
/** 配置快照回填:statistics.enabled 的 boolean 守卫在 config-sync 里,这里只应用。 */