Repository navigation
Expand file tree
/
Copy pathshared.js
More file actions
526 lines (492 loc) · 22.4 KB
/
Copy pathshared.js
File metadata and controls
526 lines (492 loc) · 22.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
// ── 共享常量 ────────────────────────────────────────────────
const DAILY_PREFIX = 'traffic_daily_';
const DL_DAILY_PREFIX = 'download_daily_';
const RAW_PREFIX = 'traffic_raw_';
const DL_RAW_PREFIX = 'download_raw_';
const STORAGE_DATES = 'tracked_dates';
const EXCLUDED_KEY = 'excluded_domains';
const DOMAIN_GROUP_KEY = 'domain_group_map';
// chrome.storage.session 崩溃恢复键(background 写入、dataService 重置时清理,故放共享作用域)
const STORAGE_PENDING = 'pending_data';
const STORAGE_PENDING_RAW = 'pending_data_raw';
const STORAGE_PENDING_DL = 'pending_downloads';
const STORAGE_PENDING_DL_RAW = 'pending_downloads_raw';
// 重置数据时通知后台清空内存里的待 flush 队列(否则后台会把重置前采集的数据写回来)
const MSG_RESET_PENDING = 'flowwatch:reset-pending';
const DEFAULT_FAVICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2Ij48cmVjdCB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHJ4PSIzIiBmaWxsPSIjZThlY2Y0Ii8+PGNpcmNsZSBjeD0iOCIgY3k9IjYiIHI9IjMiIGZpbGw9IiNkMGQ1ZGQiLz48cGF0aCBkPSJNNCAxMiBRIDggMTQgMTIgMTIiIHN0cm9rZT0iI2QwZDVkZCIgc3Ryb2tlLXdpZHRoPSIxLjUiIGZpbGw9Im5vbmUiLz48L3N2Zz4=';
// ── 主题切换图标(线性 SVG,stroke 跟随 currentColor) ────────
// 太阳图标按 13~15px 的实际显示尺寸优化:圆加大、射线缩短并加大线宽,
// 否则 8 条射线在十几像素的画布里会糊成一个点。
const THEME_ICON = {
moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 5.3l2.1 2.1"/></svg>'
};
// ── 域名归一化 ────────────────────────────────────────────────
/**
* 多段 TLD / 平台后缀表(模块级常量,只构建一次)。
* 覆盖国家二级域(com.cn/co.uk…)与允许用户自由注册子域的平台域名
* (github.io、vercel.app、s3.amazonaws.com…——这些必须保留子域,否则所有
* 用户站点会被合并成同一个「github.io」)。
* 维护提示:条目写在数组里,重复项由下方 Set 自然去重,但请勿留重复(可读性)。
*/
const MULTI_PART_TLDS = new Set([
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', 'mil.cn',
'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'go.jp', 'ed.jp',
'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'net.uk', 'ltd.uk', 'plc.uk',
'com.tw', 'org.tw', 'gov.tw', 'net.tw', 'edu.tw',
'com.hk', 'org.hk', 'gov.hk', 'edu.hk', 'net.hk',
'co.kr', 'or.kr', 'ne.kr', 'go.kr', 'ac.kr', 'pe.kr', 're.kr',
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au',
'co.nz', 'org.nz', 'net.nz', 'ac.nz', 'govt.nz',
'co.in', 'org.in', 'net.in', 'gov.in', 'ac.in', 'edu.in', 'firm.in', 'gen.in', 'ind.in',
'co.za', 'org.za', 'net.za', 'gov.za', 'ac.za', 'alt.za',
'com.br', 'org.br', 'net.br', 'gov.br', 'edu.br',
'com.mx', 'org.mx', 'net.mx', 'gob.mx', 'edu.mx',
'com.ar', 'org.ar', 'net.ar', 'gov.ar', 'edu.ar',
'co.il', 'org.il', 'net.il', 'ac.il', 'gov.il',
'co.th', 'or.th', 'go.th', 'ac.th', 'in.th', 'net.th',
'com.sg', 'org.sg', 'gov.sg', 'edu.sg', 'net.sg',
'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'sch.id',
'com.tr', 'org.tr', 'net.tr', 'gov.tr', 'edu.tr', 'biz.tr',
'co.at', 'or.at', 'ac.at', 'gv.at',
'co.hu', 'info.hu', 'org.hu', 'priv.hu',
'co.ve', 'com.ve', 'edu.ve', 'gob.ve', 'info.ve', 'net.ve', 'org.ve', 'web.ve',
'co.ug', 'ac.ug', 'go.ug', 'ne.ug', 'or.ug', 'org.ug', 'sc.ug',
'com.ua', 'edu.ua', 'gov.ua', 'net.ua', 'org.ua',
'com.eg', 'edu.eg', 'gov.eg', 'net.eg', 'org.eg',
'com.ng', 'edu.ng', 'gov.ng', 'net.ng', 'org.ng', 'sch.ng',
'com.pk', 'edu.pk', 'gob.pk', 'gov.pk', 'net.pk', 'org.pk', 'web.pk',
'com.ph', 'edu.ph', 'gov.ph', 'net.ph', 'org.ph',
'co.ke', 'ac.ke', 'go.ke', 'ne.ke', 'or.ke', 'org.ke',
'nom.ad', 'co.ma', 'net.ma', 'gov.ma', 'org.ma', 'press.ma',
'co.bn', 'com.bn', 'edu.bn', 'gov.bn', 'net.bn', 'org.bn',
'co.cr', 'ac.cr', 'ed.cr', 'fi.cr', 'go.cr', 'or.cr', 'sa.cr',
'com.do', 'edu.do', 'gob.do', 'gov.do', 'mil.do', 'net.do', 'org.do',
'com.gt', 'edu.gt', 'gob.gt', 'ind.gt', 'mil.gt', 'net.gt', 'org.gt',
'com.sv', 'edu.sv', 'gob.sv', 'net.sv', 'org.sv', 'red.sv',
'com.pe', 'edu.pe', 'gob.pe', 'mil.pe', 'net.pe', 'org.pe', 'nom.pe',
'com.ec', 'edu.ec', 'fin.ec', 'gob.ec', 'info.ec', 'med.ec', 'net.ec', 'org.ec', 'pro.ec',
'com.py', 'coop.py', 'edu.py', 'gov.py', 'mil.py', 'net.py', 'org.py',
'com.uy', 'edu.uy', 'gub.uy', 'mil.uy', 'net.uy', 'org.uy',
// 允许用户自由注册子域的平台(必须保留子域)
'blogspot.com', 'blogspot.co.uk', 'wordpress.com', 'github.io', 'firebaseapp.com',
'netlify.app', 'vercel.app', 'pages.dev', 'fly.dev', 'railway.app',
's3.amazonaws.com', 'cloudfront.net', 'azurewebsites.net',
'herokuapp.com', 'herokussl.com', 'onrender.com',
'r2.dev', 'workers.dev', 'deno.dev'
]);
/**
* 域名归一化:去掉 www、识别多段 TLD,返回注册域
* @param {string} hostname 完整主机名
* @returns {string} 根域名(IP/localhost 原样返回)
*/
function getRootDomain(hostname) {
hostname = hostname.toLowerCase().replace(/\.+$/, '');
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || /^\[/.test(hostname)) return hostname;
if (hostname === 'localhost' || hostname === '127.0.0.1') return hostname;
let parts = hostname.split('.');
if (parts[0] === 'www') parts = parts.slice(1);
if (parts.length <= 2) return parts.join('.');
const lastTwo = parts.slice(-2).join('.');
const lastThree = parts.slice(-3).join('.');
const lastFour = parts.slice(-4).join('.');
if (MULTI_PART_TLDS.has(lastFour) && parts.length >= 5) return parts.slice(-5).join('.');
if (MULTI_PART_TLDS.has(lastThree) && parts.length >= 4) return parts.slice(-4).join('.');
if (MULTI_PART_TLDS.has(lastTwo) && parts.length >= 3) return parts.slice(-3).join('.');
return parts.slice(-2).join('.');
}
// ── 平台域名 → 主站映射(单一数据源)──────────────────────────
// 用途有两个,共用同一张表,避免两处维护漂移:
// 1) favicon:CDN 域名自身没有图标,按主站去取
// 2) 域名归组:同一主站的关联域名合并统计(如 bilivideo.com → bilibili.com)
// 遇到新的平台 CDN 域名,在这里补一条即可同时覆盖图标与归组。
// 注意:本文件先于 dataService.js 求值,故此表定义在此、由 dataService 引用。
const PRESET_GROUP_MAP = {
// 哔哩哔哩
'hdslb.com': 'bilibili.com',
'bilivideo.cn': 'bilibili.com',
'bilivideo.com': 'bilibili.com',
'biliimg.com': 'bilibili.com',
'biliapi.net': 'bilibili.com',
'biliapi.com': 'bilibili.com',
'acgvideo.com': 'bilibili.com',
// GitHub
'githubassets.com': 'github.com',
'githubusercontent.com': 'github.com',
'github.io': 'github.com',
// 阿里 / 淘宝
'aliyuncs.com': 'aliyun.com',
'alicdn.com': 'aliyun.com',
'taobaocdn.com': 'taobao.com',
'tbcdn.cn': 'taobao.com',
'mmstat.com': 'taobao.com',
// 腾讯 / QQ
'qpic.cn': 'qq.com',
'qlogo.cn': 'qq.com',
'gtimg.cn': 'qq.com',
'gtimg.com': 'qq.com',
'myqcloud.com': 'tencent.com',
'qcloud.com': 'tencent.com',
'tencentcs.com': 'tencent.com',
// 字节跳动 / 抖音 / 头条
'volccdn.com': 'volcengine.com',
'byteimg.com': 'douyin.com',
'bytecdn.cn': 'douyin.com',
'douyinstatic.com': 'douyin.com',
'pstatp.com': 'toutiao.com',
'byteacctimg.com': 'toutiao.com',
// CSDN
'csdnimg.cn': 'csdn.net',
// 京东 / 拼多多
'360buyimg.com': 'jd.com',
'pddpic.com': 'yangkeduo.com',
// 网易 / 百度 / 华为 / 小米
'126.net': '163.com',
'ws126.net': '163.com',
'bdstatic.com': 'baidu.com',
'bcebos.com': 'baidu.com',
'myhuaweicloud.com': 'huawei.com',
'mi-img.com': 'xiaomi.com',
// 微博 / 知乎
'sinaimg.cn': 'weibo.com',
'sinajs.cn': 'weibo.com',
'zhimg.com': 'zhihu.com'
};
/**
* 在预置表里查找域名所属主站:先精确匹配,再按标签逐级向上做后缀匹配。
* 后缀匹配专治「平台域名保留了子域」的场景——github.io / pages.dev / vercel.app
* 这类站点,getRootDomain 保留的是用户子域(user.github.io),映射表里的键是
* 平台域名(github.io),精确匹配永远命不中,图标就退化成灰块。
* 按标签切分而非字符串 endsWith,避免 xjpgithub.io 之类误命中。
* @param {string} domain 已归一化的域名
* @returns {string|null} 主站域名或 null(无映射)
*/
function lookupPresetOwner(domain) {
if (!domain) return null;
const exact = PRESET_GROUP_MAP[domain];
if (exact) return exact;
const labels = domain.split('.');
// 从去掉最左标签开始逐级尝试,至少保留两级(顶级域本身不参与匹配)
for (let i = 1; i < labels.length - 1; i++) {
const owner = PRESET_GROUP_MAP[labels.slice(i).join('.')];
if (owner) return owner;
}
return null;
}
// ── Favicon:三级解析 + 缓存 + 并发控制 ──────────────────────
// 1) 直连 https://{domain}/favicon.ico(原域 → 根域 → 主站)
// 2) 抓取主站主页 HTML,解析 <link rel="icon"> 声明(解决 favicon 不在标准路径的站点)
// 3) favicon.im 兜底(Cloudflare,国内可达;不可达时自动熔断,之后不再尝试)
// 全部失败 → 灰色 SVG 占位
//
// 请求头说明:UA / Referer 都是 forbidden header name,浏览器会静默丢弃 JS 设置,
// 扩展页面同样受限,所以"给抓取带 UA"在浏览器里做不到。能生效的是 referrerPolicy,
// 用它避免把 chrome-extension:// 来源暴露给有防盗链的站点(否则图标 403 → 灰块)。
const faviconCache = new Map(); // root domain -> Promise<url|null>,会话内去重
const FAVICON_MAX_CONCURRENCY = 6; // 同时最多 6 个解析任务,避免一次渲染打爆网络
let faviconActive = 0;
const faviconQueue = [];
let faviconImDown = false; // favicon.im 熔断开关
function faviconAcquire() {
return new Promise(resolve => {
if (faviconActive < FAVICON_MAX_CONCURRENCY) { faviconActive++; resolve(); }
else faviconQueue.push(resolve);
});
}
function faviconRelease() {
const next = faviconQueue.shift();
if (next) next(); else faviconActive--;
}
// 用隐藏 Image 探测 URL 能否真实加载(与最终展示一致的判定)
/**
* 用隐藏 Image 探测 URL 能否真实加载(与最终展示一致的判定)
* @param {string} url 图片地址
* @param {number} [timeoutMs=4000] 超时时间
* @returns {Promise<boolean>}
*/
function probeImageUrl(url, timeoutMs = 4000) {
return new Promise(resolve => {
const img = new Image();
let settled = false;
const finish = ok => { if (!settled) { settled = true; clearTimeout(timer); resolve(ok); } };
const timer = setTimeout(() => finish(false), timeoutMs);
img.onload = () => finish(true);
img.onerror = () => finish(false);
// 不把 chrome-extension:// 来源暴露给目标站点,避免防盗链直接 403 → 灰块
img.referrerPolicy = 'no-referrer';
img.src = url;
});
}
/**
* 解析单个 <link> 标签的属性(容忍无引号写法:rel=icon href=/a.ico)
* @param {string} tag 标签文本
* @param {string} name 属性名
* @returns {string|null} 属性值
*/
function readLinkAttr(tag, name) {
const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i');
const m = re.exec(tag);
if (!m) return null;
return m[1] ?? m[2] ?? m[3] ?? null;
}
/**
* 给一个 <link rel=...> 候选打分,分值越高越优先展示。
* 排除项:mask-icon(Safari 单色遮罩,16px 下是纯黑剪影,比灰块更糟)。
* @param {string} tag 标签文本
* @returns {{href: string, score: number}|null} 候选或 null(不合格)
*/
function scoreIconLink(tag) {
const rel = (readLinkAttr(tag, 'rel') || '').toLowerCase();
if (!/\bicon\b/.test(rel)) return null; // rel 不含 icon 的一律跳过
if (/\bmask(-|\s)?icon\b/.test(rel)) return null; // 单色遮罩图标,展示效果差
const href = readLinkAttr(tag, 'href');
if (!href) return null;
if (/^data:/i.test(href)) return null; // 内联 data URI 无法稳定复用,交给后续层级
const isAppleTouch = /\bapple-touch-icon/.test(rel);
const isShortcut = /\bshortcut/.test(rel);
let score = isAppleTouch ? 40 : 100; // apple-touch 是主屏图标,圆角大、缩到 16px 发糊
if (isShortcut && !isAppleTouch) score = 100; // rel="shortcut icon" 等价于 icon
const type = (readLinkAttr(tag, 'type') || '').toLowerCase();
if (type.includes('svg')) score += 12; // SVG 任意缩放清晰
const sizes = (readLinkAttr(tag, 'sizes') || '').toLowerCase();
if (sizes === 'any') {
score += 15; // sizes=any 通常是 SVG
} else {
// 取最大边长,越接近常见展示尺寸(16/32)越优先;过小或过大都降权
const nums = [...sizes.matchAll(/(\d+)x(\d+)/g)].map(m => Math.max(+m[1], +m[2]));
if (nums.length) {
const best = Math.max(...nums);
if (best >= 32 && best <= 64) score += 14;
else if (best >= 16 && best < 32) score += 10;
else if (best > 64 && best <= 256) score += 4;
else if (best < 16) score += 2;
else score += 1; // >256 的大图,下载开销大
}
}
return { href, score };
}
/**
* 从 HTML 提取最合适的图标 <link> href。
* 按 rel 语义与 sizes 打分择优,而不是"第一个匹配就算"——
* 否则 mask-icon / apple-touch-icon 排在前面时会选到难看的图标。
* @param {string} html HTML 文本
* @param {string} baseUrl 用于解析相对路径
* @returns {string|null} 图标绝对 URL
*/
function extractIconHref(html, baseUrl) {
const linkRe = /<link\b[^>]*>/gi;
let best = null;
let bestScore = -1;
let m;
while ((m = linkRe.exec(html))) {
const cand = scoreIconLink(m[0]);
if (!cand) continue;
// 同分保留先出现的(文档顺序即作者的优先级表达)
if (cand.score > bestScore) {
try {
const url = new URL(cand.href, baseUrl).href;
best = url;
bestScore = cand.score;
} catch { /* 非法 URL 跳过 */ }
}
}
return best;
}
// 抓取域名主页前 64KB,提取 icon 声明(扩展页面有 host_permissions,不受 CORS 限制)
/**
* 抓取域名主页前 64KB,解析 <link rel="icon"> 声明
* @param {string} domain 域名
* @returns {Promise<string|null>} 图标 URL 或 null
*/
async function parseIconFromHtml(domain) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 4000);
try {
const resp = await fetch(`https://${domain}/`, {
signal: controller.signal,
redirect: 'follow',
// UA/Referer 是 forbidden header,JS 设了也会被丢弃;referrerPolicy 是合法选项,
// 用它避免把 chrome-extension:// 来源发给站点(部分站点会因此直接 403)。
referrerPolicy: 'no-referrer'
});
if (!resp.ok) return null;
let text = '';
const reader = resp.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
if (text.length > 65536) break;
}
reader.cancel();
return extractIconHref(text, `https://${domain}/`);
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/**
* favicon 三级解析主流程:直连(原域→根域→CDN主站) → HTML 解析 → favicon.im 兜底
* @param {string} domain 域名
* @returns {Promise<string|null>} 图标 URL 或 null(灰块)
*/
async function doResolveFavicon(domain) {
const root = getRootDomain(domain);
// 后缀匹配:user.github.io 这类保留子域的平台站点也能找到主站(github.com)
const owner = lookupPresetOwner(domain) || lookupPresetOwner(root);
const primary = owner || root; // HTML 解析与第三方兜底优先用主站
// 候选直连列表:原域 → 根域 → 主站,去重保序
const candidates = [...new Set([domain, root, owner].filter(Boolean))];
// 1) 直连 /favicon.ico
for (const cand of candidates) {
const url = `https://${cand}/favicon.ico`;
await faviconAcquire();
try {
if (await probeImageUrl(url, 3000)) return url;
} finally { faviconRelease(); }
}
// 2) 主站主页 HTML 解析(兼容 favicon 不在标准路径的站点)
await faviconAcquire();
try {
const iconUrl = await parseIconFromHtml(primary);
if (iconUrl && await probeImageUrl(iconUrl, 3000)) return iconUrl;
} finally { faviconRelease(); }
// 3) favicon.im 兜底(熔断:网络不可达时试一次就关,避免每次渲染都白等)
if (!faviconImDown) {
await faviconAcquire();
try {
const im = `https://favicon.im/${primary}`;
if (await probeImageUrl(im, 4000)) return im;
faviconImDown = true;
} finally { faviconRelease(); }
}
return null;
}
/**
* favicon URL 解析入口(会话级缓存,Promise 去重)
* @param {string} domain 域名
* @returns {Promise<string|null>}
*/
function resolveFaviconUrl(domain) {
if (!faviconCache.has(domain)) {
faviconCache.set(domain, doResolveFavicon(domain));
}
return faviconCache.get(domain);
}
/**
* 创建 favicon <img> 元素:先显示灰色占位,异步解析成功后替换为真实图标
* @param {string} domain 域名
* @returns {HTMLImageElement}
*/
function createFavicon(domain) {
const img = new Image();
img.className = 'domain-favicon';
img.alt = '';
img.loading = 'lazy';
// 同 probeImageUrl:不把扩展页来源暴露给目标站点,避免有防盗链的图标 403 → 灰块
img.referrerPolicy = 'no-referrer';
img.src = DEFAULT_FAVICON;
resolveFaviconUrl(domain).then(url => {
if (url) img.src = url; // 解析完成时若元素已被移除,设置 src 无害
});
return img;
}
// ── 主题(暗色模式)────────────────────────────────────────
const THEME_KEY = 'flowwatch-theme'; // 'auto' | 'light' | 'dark'
/** @returns {'auto'|'light'|'dark'} 用户存储的主题设置 */
function getStoredTheme() {
const v = localStorage.getItem(THEME_KEY);
return (v === 'light' || v === 'dark') ? v : 'auto';
}
/**
* 应用主题到 <html data-theme>:auto 时按系统偏好解析为 light/dark 显式设置
* @param {'auto'|'light'|'dark'} theme
*/
function applyTheme(theme) {
const effective = theme === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.dataset.theme = effective;
}
/** 页面加载时初始化主题;auto 模式下系统主题变化时实时跟随 */
function initTheme() {
applyTheme(getStoredTheme());
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (getStoredTheme() === 'auto') applyTheme('auto');
});
}
/** 两态切换:在当前生效主题上取反(浅 ⇄ 深),持久化为显式主题。恢复跟随系统用右键(见页面绑定) */
function toggleTheme() {
const next = currentEffectiveTheme() === 'dark' ? 'light' : 'dark';
localStorage.setItem(THEME_KEY, next);
applyTheme(next);
return next;
}
/** @returns {'light'|'dark'} 当前实际生效的主题(含系统偏好) */
function currentEffectiveTheme() {
const t = getStoredTheme();
if (t !== 'auto') return t;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// ── 数字滚动动画(popup/dashboard 共用)──────────────────────
/**
* 数字滚动动画:从 0 缓动到目标值(easeOutCubic)
* @param {HTMLElement} el 目标元素
* @param {number} target 目标数值
* @param {(v:number)=>string} formatFn 格式化函数
* @param {number} [duration=500] 动画时长 ms
*/
function animateNumber(el, target, formatFn, duration = 500) {
// 从当前显示值起滚到目标值,而不是每次归零重滚。
// 归零重滚在切换栏目时会让数字先骤降到 0 再爬回来,深色模式下表现为整块变暗闪一下。
const from = Number(el.dataset.value);
const start = Number.isFinite(from) ? from : 0;
el.dataset.value = String(target);
// 差值太小就没必要动画,直接落到终值(避免长时间在低值区间徘徊)
if (Math.abs(target - start) < 1e-6) {
el.textContent = formatFn(target);
return;
}
const startTime = performance.now();
function tick(now) {
const t = Math.min((now - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
el.textContent = formatFn(start + (target - start) * eased);
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
/**
* 字节数格式化为可读字符串
* @param {number} bytes 字节数
* @returns {string} 如 "1.2 GB"
*/
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
}
/**
* 最近 N 天(含今天)的日期 key 数组,按时间升序(日历日,无数据日也在内)
* @param {number} days 天数(如 7 / 30)
* @returns {string[]} 'YYYY-MM-DD' 数组
*/
function getRecentDateKeys(days) {
const dates = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(now.getDate() - i);
dates.push(getDateKey(d));
}
return dates;
}
function getDateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function getMonthKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}