官网:桌面模拟改为每项独立下拉与主窗口,更新截图、隐私说明与更新日志

This commit is contained in:
gentpan committed 2026-09-13 05:39:10 +05:00
1 parent 8875b6e709
commit 3b8b961470
16 files changed
+2167

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
/* OpenStats 官网 · 页面外壳:主题切换、菜单栏下拉菜单与时钟 */
(() => {
"use strict";
const root = document.documentElement;
const THEME_KEY = "os-theme";
const media = window.matchMedia("(prefers-color-scheme: dark)");
function saved() {
try { return localStorage.getItem(THEME_KEY); } catch { return null; }
}
function applyTheme(theme, persist) {
root.dataset.theme = theme;
if (!persist) return;
try { localStorage.setItem(THEME_KEY, theme); } catch { /* 隐私模式下不可用,忽略 */ }
}
applyTheme(saved() || (media.matches ? "dark" : "light"), false);
// 没有手动选过时跟随系统
media.addEventListener("change", (e) => { if (!saved()) applyTheme(e.matches ? "dark" : "light", false); });
window.OpenStatsSite = {
toggleTheme() { applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true); },
};
document.getElementById("themeToggle")?.addEventListener("click", (e) => {
e.stopPropagation();
window.OpenStatsSite.toggleTheme();
});
// ── 菜单栏下拉菜单:点击展开,展开后悬停切换,点外部或 Esc 收起 ──
const menus = [...document.querySelectorAll(".mb-menu")];
let openMenu = null;
function setMenu(menu) {
for (const m of menus) {
const open = m === menu;
m.classList.toggle("is-open", open);
m.querySelector(".mb-title")?.setAttribute("aria-expanded", String(open));
}
openMenu = menu;
}
for (const menu of menus) {
const title = menu.querySelector(".mb-title");
title?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
setMenu(openMenu === menu ? null : menu);
});
menu.addEventListener("mouseenter", () => { if (openMenu && openMenu !== menu) setMenu(menu); });
menu.querySelector(".mb-drop")?.addEventListener("click", () => setMenu(null));
}
document.addEventListener("click", (e) => { if (openMenu && !openMenu.contains(e.target)) setMenu(null); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") setMenu(null); });
// ── 时钟:按访客本地时间显示 ──
const clock = document.getElementById("menubarClock");
const format = new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false });
function renderClock() {
if (!clock) return;
const now = new Date();
const parts = Object.fromEntries(format.formatToParts(now).map((p) => [p.type, p.value]));
clock.textContent = `${parts.month}月${parts.day}日 ${parts.weekday} ${parts.hour}:${parts.minute}`;
clock.dateTime = now.toISOString();
}
renderClock();
setInterval(renderClock, 15000);
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-cn" viewBox="0 0 640 480">
<defs>
<path id="cn-a" fill="#ff0" d="M-.6.8 0-1 .6.8-1-.3h2z"/>
</defs>
<path fill="#ee1c25" d="M0 0h640v480H0z"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(71.9991 0 0 72 120 120)"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(-12.33562 -20.5871 20.58684 -12.33577 240.3 48)"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(-3.38573 -23.75998 23.75968 -3.38578 288 95.8)"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(6.5991 -23.0749 23.0746 6.59919 288 168)"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(14.9991 -18.73557 18.73533 14.99929 240 216)"/>
</svg>

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

+410
View File
@@ -0,0 +1,410 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenStats — Mac 的状态,抬眼就看见</title>
<meta name="description" content="OpenStats 把 CPU、GPU、内存、网络与温度放进 macOS 菜单栏,还能调风扇、防休眠、一键清理。数据在本机读取,没有账号与统计上报。">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0b0b0d" media="(prefers-color-scheme: dark)">
<link rel="canonical" href="https://getopenstats.com/">
<meta property="og:title" content="OpenStats — Mac 的状态,抬眼就看见">
<meta property="og:description" content="CPU、GPU、内存、网络与温度常驻菜单栏,还能调风扇、防休眠、一键清理。">
<meta property="og:type" content="website">
<meta property="og:url" content="https://getopenstats.com/">
<meta property="og:site_name" content="OpenStats">
<meta property="og:locale" content="zh_CN">
<meta property="og:image" content="https://getopenstats.com/assets/window-light.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
<script>
// 在样式生效前确定主题,避免深色模式下先闪一下白底
try {
const saved = localStorage.getItem("os-theme");
document.documentElement.dataset.theme = saved || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
} catch { document.documentElement.dataset.theme = "light"; }
</script>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="replica.css">
</head>
<body>
<!-- ── 首屏:整屏就是一台 Mac ───────────────────────────────────────
菜单栏同时是全站导航;右侧的 OpenStats 读数、点开的详情弹窗与主窗口都是 HTML 重建,
数据由 replica.js 模拟。往下滚才是普通的介绍页。 -->
<header class="desktop" id="top">
<div class="desktop__wall" aria-hidden="true"></div>
<nav class="menubar" aria-label="站点导航">
<svg class="menubar__apple" viewBox="0 0 14 17" fill="currentColor" aria-hidden="true">
<path d="M11.5 9c0-1.9 1.5-2.8 1.6-2.9-.9-1.3-2.2-1.5-2.7-1.5-1.1-.1-2.2.7-2.8.7-.6 0-1.5-.7-2.4-.6-1.2 0-2.4.7-3 1.8-1.3 2.2-.3 5.4 1 7.2.6.8 1.3 1.8 2.2 1.7.9 0 1.2-.6 2.2-.6 1 0 1.3.6 2.2.5.9 0 1.5-.8 2.1-1.7.7-1 .9-1.9.9-1.9s-1.8-.7-1.8-2.7zM9.7 3.2c.5-.6.8-1.4.7-2.2-.7 0-1.6.5-2.1 1.1-.5.6-.9 1.4-.8 2.2.8.1 1.7-.4 2.2-1.1z"/>
</svg>
<div class="mb-menu">
<a class="mb-title mb-title--app" href="#top" aria-haspopup="menu" aria-expanded="false">OpenStats</a>
<div class="mb-drop" role="menu">
<a role="menuitem" href="#features">关于 OpenStats</a>
<a role="menuitem" href="#download">下载 for Mac…</a>
<hr>
<a role="menuitem" href="https://github.com/gentpan/OpenStats">查看源码<span class="mb-drop__ext" aria-hidden="true">↗</span></a>
</div>
</div>
<div class="mb-menu">
<a class="mb-title" href="#features" aria-haspopup="menu" aria-expanded="false">功能</a>
<div class="mb-drop" role="menu">
<a role="menuitem" href="#styles">菜单栏风格</a>
<a role="menuitem" href="#network">网络详情与 DNS</a>
<a role="menuitem" href="#themes">主窗口与主题</a>
<a role="menuitem" href="#thermal">风扇与防休眠</a>
<a role="menuitem" href="#cleanup">清理与维护</a>
</div>
</div>
<div class="mb-menu">
<a class="mb-title" href="#privacy" aria-haspopup="menu" aria-expanded="false">隐私</a>
<div class="mb-drop" role="menu">
<a role="menuitem" href="#privacy">数据留在本机</a>
<a role="menuitem" href="#faq">辅助工具做什么</a>
</div>
</div>
<div class="mb-menu">
<a class="mb-title" href="#changelog" aria-haspopup="menu" aria-expanded="false">更新日志</a>
<div class="mb-drop" role="menu">
<a role="menuitem" href="#changelog">最近更新</a>
<a role="menuitem" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">完整更新日志<span class="mb-drop__ext" aria-hidden="true">↗</span></a>
</div>
</div>
<div class="mb-menu">
<a class="mb-title" href="#faq" aria-haspopup="menu" aria-expanded="false">帮助</a>
<div class="mb-drop" role="menu">
<a role="menuitem" href="#faq">常见问题</a>
<hr>
<a role="menuitem" href="https://github.com/gentpan/OpenStats/issues">反馈问题<span class="mb-drop__ext" aria-hidden="true">↗</span></a>
</div>
</div>
<div class="menubar__right">
<!-- OpenStats 的菜单栏图标:每个指标一个,内容由 replica.js 按当前风格绘制 -->
<div class="mb-items" id="mbItems"></div>
<svg class="menubar__extra" width="19" height="11" viewBox="0 0 19 11" fill="none" stroke="currentColor" stroke-width="1.1" aria-hidden="true">
<rect x="0.6" y="0.6" width="14" height="9.8" rx="2.6"/><rect x="2.2" y="2.2" width="9" height="6.6" rx="1.4" fill="currentColor" stroke="none"/><path d="M16.4 4v3" stroke-linecap="round"/>
</svg>
<svg class="menubar__extra" width="15" height="11" viewBox="0 0 15 11" fill="none" stroke="currentColor" stroke-width="1.3" aria-hidden="true">
<path d="M1 4a9 9 0 0 1 13 0M3.5 6.5a5.5 5.5 0 0 1 8 0"/><circle cx="7.5" cy="9.4" r="1" fill="currentColor" stroke="none"/>
</svg>
<button class="mb-icon" id="themeToggle" type="button" aria-label="切换浅色 / 深色">
<svg class="mb-icon__moon" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M12 8.6A5.4 5.4 0 1 1 5.4 2a4.2 4.2 0 0 0 6.6 6.6z"/></svg>
<svg class="mb-icon__sun" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true"><circle cx="7" cy="7" r="2.6"/><path d="M7 .8v1.4M7 11.8v1.4M.8 7h1.4M11.8 7h1.4M2.6 2.6l1 1M10.4 10.4l1 1M11.4 2.6l-1 1M3.6 10.4l-1 1"/></svg>
</button>
<time class="menubar__clock" id="menubarClock"></time>
</div>
</nav>
<!-- 刘海岛:收起时是刘海,悬停或点击展开实时摘要 -->
<button class="island" id="island" type="button" aria-expanded="false" aria-label="展开 OpenStats 刘海岛">
<span class="island__compact">
<span class="island__dot"></span>
<span class="island__mini" data-bind="island-cpu">CPU 24%</span>
</span>
<span class="island__expanded" aria-hidden="true">
<span class="island__row">
<span class="island__brand"><img src="assets/icon.png" alt="" width="18" height="18">OpenStats</span>
<span class="island__health" data-bind="island-health">各项指标正常</span>
</span>
<span class="island__grid">
<span class="island__tile"><span class="island__label">CPU</span><span class="island__value" data-bind="island-cpu-value">24%</span><span class="island__bar"><i data-bind="island-cpu-bar"></i></span></span>
<span class="island__tile"><span class="island__label">GPU</span><span class="island__value" data-bind="island-gpu-value">12%</span><span class="island__bar"><i data-bind="island-gpu-bar"></i></span></span>
<span class="island__tile"><span class="island__label">内存</span><span class="island__value" data-bind="island-mem-value">68%</span><span class="island__bar"><i data-bind="island-mem-bar"></i></span></span>
<span class="island__tile"><span class="island__label">温度</span><span class="island__value" data-bind="island-temp-value">52°</span><span class="island__bar"><i data-bind="island-temp-bar"></i></span></span>
</span>
<span class="island__foot">
<span><i class="dot dot--up"></i><b data-bind="island-up">12 KB/s</b></span>
<span><i class="dot dot--down"></i><b data-bind="island-down">1.4 MB/s</b></span>
<span data-bind="island-fan">风扇 1,840 RPM</span>
</span>
</span>
</button>
<!-- 左侧桌面图标 -->
<nav class="desk-icons" aria-label="快捷入口">
<a class="desk-icon" href="#download">
<span class="desk-icon__art desk-icon__art--dmg" aria-hidden="true"><img src="assets/icon.png" alt="" width="40" height="40"></span>
<span class="desk-icon__name">OpenStats.dmg</span>
</a>
<a class="desk-icon" href="https://github.com/gentpan/OpenStats#readme">
<span class="desk-icon__art desk-icon__art--doc" aria-hidden="true"><span>MD</span></span>
<span class="desk-icon__name">README.md</span>
</a>
<a class="desk-icon" href="#changelog">
<span class="desk-icon__art desk-icon__art--doc" aria-hidden="true"><span>LOG</span></span>
<span class="desk-icon__name">更新日志</span>
</a>
<a class="desk-icon" href="#privacy">
<span class="desk-icon__art desk-icon__art--folder" aria-hidden="true"></span>
<span class="desk-icon__name">隐私说明</span>
</a>
</nav>
<!-- 欢迎窗口:首屏的主要文案 -->
<section class="window hero" aria-labelledby="heroTitle">
<div class="window__bar">
<span class="window__lights" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="window__title">欢迎使用 OpenStats</span>
</div>
<div class="window__body hero__body">
<img class="hero__icon" src="assets/icon.png" alt="OpenStats 图标" width="72" height="72">
<h1 class="hero__title" id="heroTitle">Mac 的状态,抬眼就看见</h1>
<p class="hero__lead">CPU、GPU、内存、网络与温度常驻菜单栏,还能调风扇、防休眠、一键清理。所有数据在本机读取,没有账号与统计上报。</p>
<div class="hero__actions">
<a class="btn btn--primary" href="#download">下载 for Mac</a>
<a class="btn btn--secondary" href="https://github.com/gentpan/OpenStats">GitHub</a>
</div>
<p class="hero__meta">macOS 14 及以上 · Apple Silicon · MIT 开源</p>
<p class="hero__hint">点右上角菜单栏里的 CPU、内存或网速,看看每一项的详情 ↗</p>
<button class="hero__link" id="heroOpenWindow" type="button">或者打开 OpenStats 主窗口</button>
</div>
</section>
<!-- 菜单栏详情弹窗与主窗口:由 replica.js 渲染 -->
<div class="os-popover" id="osPopover" role="dialog" aria-label="OpenStats 详情" hidden></div>
<div class="os-window" id="osWindow" role="dialog" aria-label="OpenStats 主窗口" hidden></div>
<!-- 程序坞 -->
<nav class="dock" aria-label="程序坞">
<a class="dock__item" href="#features" title="访达"><span class="dock__art dock__art--finder" aria-hidden="true"></span></a>
<button class="dock__item" id="dockOpenStats" type="button" title="打开 OpenStats"><img class="dock__art" src="assets/icon.png" alt="OpenStats"></button>
<a class="dock__item" href="#thermal" title="风扇与防休眠"><span class="dock__art dock__art--fan" aria-hidden="true"></span></a>
<a class="dock__item" href="#cleanup" title="清理"><span class="dock__art dock__art--clean" aria-hidden="true"></span></a>
<a class="dock__item" href="https://github.com/gentpan/OpenStats" title="GitHub"><span class="dock__art dock__art--terminal" aria-hidden="true"></span></a>
<span class="dock__divider" aria-hidden="true"></span>
<a class="dock__item" href="#download" title="下载"><span class="dock__art dock__art--download" aria-hidden="true"></span></a>
</nav>
<a class="scroll-cue" href="#features" aria-label="继续了解">继续了解 ↓</a>
</header>
<main>
<section class="band" id="features">
<div class="shell">
<div class="section-head">
<h2>每个指标一个图标,点开就是详情</h2>
<p>原生 Swift 编写,只显示菜单栏时约 0.3% CPU、16 MB 内存。</p>
</div>
<ul class="feature-grid">
<li class="feature">
<h3>八种菜单栏风格</h3>
<p>双行文字、圆环、饼图、柱状历史……整体选一种统一套用,也能给单个指标单独指定。</p>
</li>
<li class="feature">
<h3>单项详情,按需显示</h3>
<p>CPU、内存、网络、GPU、温度各有一个窄弹窗,显示哪些区块由你勾选;另有侧边栏主窗口看全部。</p>
</li>
<li class="feature">
<h3>网络详情与 DNS</h3>
<p>本地与公网 IPv4 / IPv6、归属地与 ASN、连接探测格子,一键刷新 DNS 或切换 Cloudflare、Google、腾讯、阿里云。</p>
</li>
<li class="feature">
<h3>风扇调速与防休眠</h3>
<p>自动、降温、强冷、自定义风扇;屏幕常亮、合上屏幕继续运行,过热或电量过低时自动恢复。</p>
</li>
<li class="feature">
<h3>安全的清理</h3>
<p>缓存、日志、浏览器与开发者缓存先扫描预览再清理,钥匙串、Cookie 与历史记录一律不碰。</p>
</li>
<li class="feature">
<h3>数据留在本机</h3>
<p>指标来自本机内核、IOKit 与 SMC,没有账号、没有统计上报;公网 IP 查询与连接探测都可关闭。</p>
</li>
</ul>
</div>
</section>
<section class="band band--alt" id="styles">
<div class="shell">
<div class="section-head">
<h2>菜单栏风格,由你挑</h2>
<p>点一下下面的风格,页面顶部菜单栏里的 OpenStats 会立即换成这个样子。</p>
</div>
<div class="style-picker" id="stylePicker" role="radiogroup" aria-label="菜单栏风格"></div>
<div class="style-preview">
<div class="style-preview__bar style-preview__bar--light" id="stylePreviewLight"></div>
<div class="style-preview__bar style-preview__bar--dark" id="stylePreviewDark"></div>
<p class="style-preview__caption" id="styleCaption"></p>
</div>
</div>
</section>
<section class="band" id="network">
<div class="shell split">
<div class="split__text">
<h2>网络,一眼看到底</h2>
<ul class="check-list">
<li>上下镜像的流量历史,上传绿、下载蓝</li>
<li>连接探测格子:每 1 / 2 / 5 秒 ping 一次,延迟、抖动、丢包一目了然</li>
<li>本地与公网 IPv4 / IPv6、路由器、Wi‑Fi 信号,点一下就拷贝</li>
<li>归属地显示国旗、城市、ASN 与网络运营方</li>
<li>一键刷新 DNS 缓存,一键切换 Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写</li>
<li>各进程的上传与下载速率</li>
</ul>
<p class="note">公网 IP 查询与连接探测都可以在设置里关闭。修改 DNS 需要管理员权限。</p>
</div>
<figure class="shot shot--popover split__media">
<img class="shot--light" src="assets/popover-network-light.png" alt="OpenStats 网络详情弹窗" loading="lazy">
<img class="shot--dark" src="assets/popover-network-dark.png" alt="OpenStats 网络详情弹窗,深色" loading="lazy">
</figure>
</div>
</section>
<section class="band band--alt" id="themes">
<div class="shell">
<div class="section-head">
<h2>白底蓝,黑底蓝,一套外观</h2>
<p>主窗口、设置和弹窗共用同一底色,侧边栏与标题栏不分栏着色;窗口宽高都能拖动调整。</p>
</div>
<div class="shots">
<figure class="shot"><img src="assets/window-light.png" alt="OpenStats 主窗口仪表盘,浅色" loading="lazy"><figcaption>浅色</figcaption></figure>
<figure class="shot"><img src="assets/window-dark.png" alt="OpenStats 主窗口仪表盘,深色" loading="lazy"><figcaption>深色</figcaption></figure>
</div>
</div>
</section>
<section class="band" id="thermal">
<div class="shell split">
<div class="split__text">
<h2>热了就吹,要跑就别睡</h2>
<table class="mode-table">
<thead><tr><th>风扇模式</th><th>作用</th></tr></thead>
<tbody>
<tr><td>自动</td><td>交还 macOS 温控</td></tr>
<tr><td>降温</td><td>最低到最高转速之间的 60%</td></tr>
<tr><td>强冷</td><td>最高转速</td></tr>
<tr><td>自定义</td><td>滑块自由设定</td></tr>
</tbody>
</table>
<p>合盖后继续运行适合下载、渲染和远程连接。使用电池且电量低于下限时会自动关闭,避免在包里过热。</p>
<p class="note">风扇与合盖运行需要安装一次辅助工具。它只提供固定的几个操作,不执行任意命令,应用退出时自动恢复设置。</p>
</div>
<figure class="shot split__media"><img src="assets/thermal.png" alt="OpenStats 散热页面" loading="lazy"></figure>
</div>
</section>
<section class="band band--alt" id="cleanup">
<div class="shell split split--reverse">
<div class="split__text">
<h2>先看清再清理</h2>
<ul class="check-list">
<li>应用缓存、日志与崩溃报告</li>
<li>Chrome、Edge、Brave、Arc、Firefox、Safari 缓存</li>
<li>Xcode 编译缓存、模拟器与 npm 缓存</li>
<li>未完成的下载、安装包、废纸篓</li>
<li>一键刷新 DNS 缓存、释放内存</li>
</ul>
<p>只处理白名单目录;正在运行的应用会跳过,浏览器需先退出;删除前每一项再校验一次,所有操作写入日志。</p>
</div>
<figure class="shot split__media"><img src="assets/cleaner.png" alt="OpenStats 清理页面" loading="lazy"></figure>
</div>
</section>
<section class="band" id="privacy">
<div class="shell narrow">
<div class="section-head">
<h2>数据不出这台 Mac</h2>
<p>没有账号,没有统计上报。只有两项可关闭的网络功能会联网。</p>
</div>
<ul class="fact-list">
<li><b>指标来源</b><span>内核接口(host_processor_info、host_statistics64、sysctl)、IOKit 与 SMC</span></li>
<li><b>账号</b><span>不需要,也没有</span></li>
<li><b>统计上报</b><span>没有</span></li>
<li><b>公网 IP</b><span>打开网络详情时向 ipinfo.io 查询一次地址、归属地与 ASN,10 分钟内不重复,可关闭</span></li>
<li><b>连接探测</b><span>按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭</span></li>
<li><b>偏好设置</b><span>保存在本机,不含个人信息</span></li>
<li><b>源代码</b><span><a href="https://github.com/gentpan/OpenStats">MIT 开源,全部公开</a></span></li>
</ul>
</div>
</section>
<section class="band band--alt" id="download">
<div class="shell narrow download">
<img src="assets/icon.png" alt="" width="96" height="96">
<h2>下载 OpenStats</h2>
<p>使用 Developer ID 签名并经过 Apple 公证,打开时不会遇到安全拦截。</p>
<div class="hero__actions">
<a class="btn btn--primary" href="https://github.com/gentpan/OpenStats/releases/latest">下载 .dmg</a>
<a class="btn btn--secondary" href="https://github.com/gentpan/OpenStats">从源码构建</a>
</div>
<p class="hero__meta">macOS 14 Sonoma 及以上 · 简体中文界面</p>
</div>
</section>
<section class="band" id="changelog">
<div class="shell narrow">
<div class="section-head">
<h2>更新日志</h2>
<p>每天改了什么,都记在这里。</p>
</div>
<!-- changelog:start -->
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
<div class="log">
<article class="log__day">
<div class="log__head"><time datetime="2026-09-13">2026-09-13</time><span class="log__tag">未发布</span></div>
<p class="log__counts">新增 23 · 样式 5 · 修复 6</p>
<ul class="log__list">
<li><span class="log__kind">新增</span>菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。</li>
<li><span class="log__kind">新增</span>菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。</li>
<li><span class="log__kind">新增</span>网速有双行圆点、双行箭头、单行三种样式,绿色上传、蓝色下载,带完整单位。</li>
<li><span class="log__kind">新增</span>鼠标悬停在菜单栏图标上显示完整读数。</li>
<li><span class="log__kind">新增</span>菜单栏布局:每项独立(每个指标一个图标,点击弹出该项的窄详情)或合并为一个(点击打开主窗口)。每个详情里显示哪些区块可以在设置中逐个勾选。</li>
<li><span class="log__kind">新增</span>详情弹窗:CPU(负载历史、各核心、处理器信息、高占用进程)、内存(使用历史、内存构成与释放内存、高占用进程)、GPU、温度与风扇(各组温度、风扇转速与快捷模式)。</li>
<li><span class="log__kind">新增</span>网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。</li>
<li><span class="log__kind">新增</span>DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。</li>
</ul>
<a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 26 项 →</a>
</article>
</div>
<!-- changelog:end -->
<p class="log__all"><a href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">查看完整更新日志 →</a></p>
</div>
</section>
<section class="band band--alt" id="faq">
<div class="shell narrow">
<div class="section-head"><h2>常见问题</h2></div>
<details class="faq">
<summary>会不会很耗电?</summary>
<p>只显示菜单栏时只采集菜单栏用到的指标,M5 Max 实测约 0.3% CPU、16 MB 内存;锁屏、屏幕休眠和系统睡眠时暂停采样与探测。</p>
</details>
<details class="faq">
<summary>辅助工具是做什么的,安全吗?</summary>
<p>调风扇、合盖不睡眠、刷新 DNS、修改 DNS 服务器和释放内存需要系统权限,由一个通过 SMAppService 注册的辅助工具完成。它只提供这几个固定操作,不执行任意命令,DNS 地址会再次校验;只接受同一开发团队签名的 OpenStats 调用,应用退出时自动恢复风扇与睡眠设置。</p>
</details>
<details class="faq">
<summary>会联网吗?</summary>
<p>只有两项可关闭的功能会:打开网络详情时向 ipinfo.io 查询一次公网地址、归属地与 ASN(10 分钟内不重复);连接探测按你设定的间隔 ping 你选择的目标。没有账号,没有统计上报。</p>
</details>
<details class="faq">
<summary>为什么不上 App Store?</summary>
<p>App Store 的沙盒不允许访问 SMC、读取其他应用的缓存和安装特权辅助工具,而这正是大部分功能。</p>
</details>
<details class="faq">
<summary>清理会不会删掉重要文件?</summary>
<p>只清理白名单目录中的缓存与日志,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;清理前可以展开查看每一项,下载目录中的内容会先移到废纸篓。</p>
</details>
<details class="faq">
<summary>支持 Intel Mac 吗?</summary>
<p>目前在 Apple Silicon 上开发和测试,Intel 机型的风扇与温度读取尚未验证。</p>
</details>
</div>
</section>
</main>
<footer class="footer">
<div class="shell footer__inner">
<span class="footer__brand"><img src="assets/icon.png" alt="" width="20" height="20">OpenStats</span>
<span class="footer__fine">© 2026 GiantAccel, LLC · <a href="https://github.com/gentpan/OpenStats/blob/main/LICENSE">MIT 许可证</a> · <a href="https://github.com/gentpan/OpenStats">GitHub</a></span>
<span class="footer__fine">OpenStats 是独立的第三方应用,与 Apple 没有隶属关系。</span>
</div>
</footer>
<script src="app.js" defer></script>
<script src="replica.js" defer></script>
</body>
</html>
+638
View File
@@ -0,0 +1,638 @@
/* OpenStats 官网 · 首屏的 Mac 桌面与应用界面还原
面板配色、圆角、间距与应用内 DS token 一致。
菜单栏里的读数刻意使用 7–11px 字号:这是在还原原生菜单栏的真实尺寸,属于有意的例外。 */
:root {
--mb-height: 28px;
--mb-text: 14px;
--mbs-label: 7px;
--mbs-value: 10px;
--mbs-inline: 11px;
--mbs-network: 9px;
--wall-sky: #d6e4fb;
--wall-1: #b5cdf5;
--wall-2: #8fb3ee;
--wall-3: #6696e3;
--wall-4: #3f76d6;
--menubar-bg: rgba(248, 249, 250, 0.72);
--menubar-fg: #111827;
--drop-bg: #ffffff;
--drop-border: rgba(0, 0, 0, 0.1);
--window-bg: #ffffff;
--window-bar: #f3f4f6;
--dock-bg: rgba(255, 255, 255, 0.55);
--dock-border: rgba(255, 255, 255, 0.7);
/* 应用界面:白底蓝色;卡片为浅灰实色,不加边框 */
--p-bg: #ffffff;
--p-surface: #f3f4f6;
--p-elevated: #ffffff;
--p-hover: rgba(0, 0, 0, 0.05);
--p-border: rgba(0, 0, 0, 0.08);
--p-track: rgba(0, 0, 0, 0.06);
--p-text: #111827;
--p-text-2: #6b7280;
--p-text-3: #9ca3af;
--p-primary: #2563eb;
--p-secondary: #0d9488;
--p-success: #16a34a;
--p-warning: #d97706;
--p-error: #dc2626;
--p-up: #16a34a;
--p-down: #2563eb;
}
:root[data-theme="dark"] {
--wall-sky: #0d1526;
--wall-1: #13213f;
--wall-2: #182c55;
--wall-3: #1e386c;
--wall-4: #264587;
--menubar-bg: rgba(17, 19, 21, 0.62);
--menubar-fg: #f3f4f6;
--drop-bg: #25282d;
--drop-border: rgba(255, 255, 255, 0.1);
--window-bg: #1d1f23;
--window-bar: #25282d;
--dock-bg: rgba(29, 31, 35, 0.6);
--dock-border: rgba(255, 255, 255, 0.12);
--p-bg: #0b0b0d;
--p-surface: #17181b;
--p-elevated: #26282d;
--p-hover: rgba(255, 255, 255, 0.07);
--p-border: rgba(255, 255, 255, 0.09);
--p-track: rgba(255, 255, 255, 0.1);
--p-text: #f3f4f6;
--p-text-2: #9ca3af;
--p-text-3: #6b7280;
--p-primary: #3b82f6;
--p-secondary: #2dd4bf;
--p-success: #22c55e;
--p-warning: #f59e0b;
--p-error: #f87171;
--p-up: #22c55e;
--p-down: #3b82f6;
}
/* ── 桌面 ─────────────────────────────────────────────── */
.desktop {
position: relative;
min-height: max(100vh, 760px);
overflow: hidden;
isolation: isolate;
}
/* 壁纸:纯色层叠山丘,不使用渐变与位图 */
.desktop__wall {
position: absolute;
inset: 0;
z-index: -1;
background: var(--wall-sky);
}
.desktop__wall::before,
.desktop__wall::after {
content: "";
position: absolute;
left: -10%;
right: -10%;
border-radius: 50% 50% 0 0;
}
.desktop__wall::before { bottom: -35%; height: 80%; background: var(--wall-2); box-shadow: -420px -60px 0 40px var(--wall-1); }
.desktop__wall::after { bottom: -55%; height: 75%; background: var(--wall-4); box-shadow: 380px -90px 0 0 var(--wall-3); }
/* ── 菜单栏 ───────────────────────────────────────────── */
.menubar {
position: relative;
z-index: 20;
display: flex;
align-items: center;
gap: var(--space-1);
height: var(--mb-height);
padding: 0 var(--space-3);
background: var(--menubar-bg);
color: var(--menubar-fg);
font-size: var(--mb-text);
}
.menubar__apple { width: 14px; height: 17px; margin: 0 var(--space-3) 0 var(--space-1); }
.mb-menu { position: relative; }
.mb-title {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
color: inherit;
}
.mb-title:hover, .mb-menu.is-open .mb-title { color: inherit; background: rgba(127, 127, 127, 0.22); }
.mb-title--app { font-weight: 600; }
.mb-drop {
position: absolute;
top: calc(100% + var(--space-1));
left: 0;
min-width: 220px;
padding: var(--space-1);
background: var(--drop-bg);
border: 1px solid var(--drop-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-2);
display: none;
}
.mb-menu.is-open .mb-drop { display: block; }
.mb-drop a {
display: flex;
justify-content: space-between;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-sm);
color: var(--menubar-fg);
font-size: var(--text-sm);
}
.mb-drop a:hover { background: var(--color-primary); color: #fff; }
.mb-drop hr { border: 0; border-top: 1px solid var(--drop-border); margin: var(--space-1) var(--space-2); }
.mb-drop__ext { opacity: 0.6; }
.menubar__right { margin-left: auto; display: flex; align-items: center; gap: var(--space-3); }
.menubar__clock { font-variant-numeric: tabular-nums; }
.mb-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 22px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
}
.mb-icon:hover { background: rgba(127, 127, 127, 0.22); }
.mb-icon__sun { display: none; }
:root[data-theme="dark"] .mb-icon__moon { display: none; }
:root[data-theme="dark"] .mb-icon__sun { display: block; }
/* OpenStats 菜单栏读数 */
.mb-stats {
display: inline-flex;
align-items: center;
gap: var(--space-3);
height: 24px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.mb-stats:hover, .mb-stats[aria-expanded="true"] { background: rgba(127, 127, 127, 0.22); }
.mb-items { display: flex; align-items: center; gap: var(--space-1); }
.mbs { display: inline-flex; align-items: center; gap: var(--space-1); line-height: 1; }
.mbs__stack { display: inline-flex; flex-direction: column; align-items: flex-end; gap: 1px; }
.mbs__label { font-size: var(--mbs-label); font-weight: 500; opacity: 0.65; letter-spacing: 0.02em; }
.mbs__value { font-size: var(--mbs-value); font-weight: 500; font-variant-numeric: tabular-nums; min-width: 3.1ch; text-align: right; }
.mbs__inline-label { font-size: var(--mbs-value); opacity: 0.65; }
.mbs__inline-value { font-size: var(--mbs-inline); font-weight: 500; font-variant-numeric: tabular-nums; min-width: 3.1ch; text-align: right; }
.mbs__net { display: inline-flex; flex-direction: column; gap: 1px; font-size: var(--mbs-network); font-variant-numeric: tabular-nums; }
.mbs__net span { display: inline-flex; align-items: center; justify-content: space-between; gap: var(--space-1); min-width: 7.4ch; }
.mbs__net i { width: 5px; height: 5px; border-radius: 50%; }
.mbs__net .up { background: var(--p-up); }
.mbs__net .down { background: var(--p-down); }
.mbs svg { display: block; }
/* ── 刘海岛 ───────────────────────────────────────────── */
.island {
position: absolute;
z-index: 25;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 200px;
height: 32px;
padding: 0 var(--space-4);
border: 0;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
background: #000;
color: #f3f4f6;
font: inherit;
cursor: pointer;
overflow: hidden;
transition: width 0.28s ease, height 0.28s ease, border-radius 0.28s ease;
}
.island__compact { display: flex; align-items: center; justify-content: space-between; height: 32px; font-size: var(--text-xs); }
.island__dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; }
.island__mini { font-variant-numeric: tabular-nums; }
.island__expanded { display: none; }
.island.is-open { width: 380px; height: 176px; border-radius: 0 0 22px 22px; cursor: default; }
.island.is-open .island__compact { display: none; }
.island.is-open .island__expanded { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3) 0; text-align: left; }
.island__row { display: flex; align-items: center; justify-content: space-between; }
.island__brand { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); font-weight: 600; }
.island__brand img { border-radius: var(--radius-sm); }
.island__health { font-size: var(--text-xs); color: #22c55e; }
.island__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-3); }
.island__tile { display: flex; flex-direction: column; gap: var(--space-1); }
.island__label { font-size: var(--text-xs); color: #9ca3af; }
.island__value { font-size: var(--text-lg); font-weight: 600; font-variant-numeric: tabular-nums; }
.island__bar { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.14); overflow: hidden; }
.island__bar i { display: block; height: 100%; width: 0; background: #3b82f6; transition: width 0.6s ease; }
.island__foot { display: flex; justify-content: space-between; font-size: var(--text-xs); color: #9ca3af; font-variant-numeric: tabular-nums; }
.island__foot b { color: #f3f4f6; font-weight: 500; }
.dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: var(--space-1); }
.dot--up { background: #22c55e; }
.dot--down { background: #3b82f6; }
/* ── 桌面图标 ─────────────────────────────────────────── */
.desk-icons {
position: absolute;
z-index: 5;
top: calc(var(--mb-height) + var(--space-6));
left: var(--space-6);
display: grid;
gap: var(--space-4);
}
.desk-icon { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); width: 88px; padding: var(--space-2); border-radius: var(--radius-md); color: #fff; }
.desk-icon:hover { color: #fff; background: rgba(0, 0, 0, 0.16); }
.desk-icon__name { font-size: var(--text-xs); font-weight: 500; text-align: center; padding: 0 var(--space-1); border-radius: var(--radius-sm); background: rgba(0, 0, 0, 0.32); }
.desk-icon__art { width: 48px; height: 56px; display: flex; align-items: center; justify-content: center; }
.desk-icon__art--dmg img { border-radius: var(--radius-md); box-shadow: var(--shadow-1); }
.desk-icon__art--doc { background: #fff; border-radius: var(--radius-sm); box-shadow: var(--shadow-1); color: #2563eb; font-size: var(--text-xs); font-weight: 700; }
.desk-icon__art--folder { width: 56px; height: 44px; margin-top: var(--space-2); background: #60a5fa; border-radius: var(--radius-sm) var(--radius-md) var(--radius-md) var(--radius-md); position: relative; box-shadow: var(--shadow-1); }
.desk-icon__art--folder::before { content: ""; position: absolute; top: -6px; left: 0; width: 22px; height: 8px; background: #60a5fa; border-radius: var(--radius-sm) var(--radius-sm) 0 0; }
/* ── 欢迎窗口 ─────────────────────────────────────────── */
.window {
position: absolute;
z-index: 10;
background: var(--window-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-2);
overflow: hidden;
}
.window__bar { position: relative; display: flex; align-items: center; justify-content: center; height: 36px; background: var(--window-bar); border-bottom: 1px solid var(--color-border); }
.window__lights { position: absolute; left: var(--space-3); display: flex; gap: var(--space-2); }
.window__lights i { width: 12px; height: 12px; border-radius: 50%; background: #ff5f57; }
.window__lights i:nth-child(2) { background: #febc2e; }
.window__lights i:nth-child(3) { background: #28c840; }
.window__title { font-size: var(--text-xs); font-weight: 600; color: var(--color-text-secondary); }
.hero { top: 18%; left: 50%; transform: translateX(-72%); width: min(520px, calc(100vw - 2 * var(--space-6))); }
.hero__body { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-4); padding: var(--space-8); }
.hero__icon { border-radius: var(--radius-lg); }
.hero__title { font-size: var(--text-2xl); }
.hero__lead { color: var(--color-text-secondary); }
.hero__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.hero__meta { font-size: var(--text-xs); color: var(--color-text-tertiary); }
.hero__hint { font-size: var(--text-xs); color: var(--color-primary); }
.hero__link { margin-top: calc(-1 * var(--space-3)); padding: 0; border: 0; background: none; color: var(--color-text-secondary); font: inherit; font-size: var(--text-xs); text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
.hero__link:hover { color: var(--color-primary); }
/* ── 程序坞 ───────────────────────────────────────────── */
.dock {
position: absolute;
z-index: 15;
left: 50%;
bottom: var(--space-3);
transform: translateX(-50%);
display: flex;
align-items: flex-end;
gap: var(--space-2);
padding: var(--space-2);
background: var(--dock-bg);
border: 1px solid var(--dock-border);
border-radius: var(--radius-xl);
}
.dock__item {
position: relative;
display: block;
width: 52px;
height: 52px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
transition: transform 0.18s ease;
transform-origin: bottom center;
}
.dock__item:hover { transform: scale(1.22) translateY(-4px); }
.dock__item.dock__item--running::after { content: ""; position: absolute; bottom: -6px; left: 50%; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: var(--menubar-fg); opacity: 0.7; }
.dock__art { display: block; width: 52px; height: 52px; border-radius: var(--radius-lg); }
img.dock__art { border-radius: var(--radius-lg); }
.dock__art--finder { background: #3b82f6; position: relative; }
.dock__art--finder::before { content: ""; position: absolute; inset: 0 50% 0 0; background: #93c5fd; border-radius: var(--radius-lg) 0 0 var(--radius-lg); }
.dock__art--fan { background: #0d9488; position: relative; }
.dock__art--fan::before { content: ""; position: absolute; inset: 14px; border: 4px solid #fff; border-radius: 50%; border-left-color: transparent; }
.dock__art--clean { background: #16a34a; position: relative; }
.dock__art--clean::before { content: ""; position: absolute; left: 18px; right: 18px; top: 14px; bottom: 12px; background: #fff; border-radius: 2px 2px var(--radius-sm) var(--radius-sm); }
.dock__art--terminal { background: #1f2937; position: relative; }
.dock__art--terminal::before { content: ">_"; position: absolute; left: 10px; top: 12px; color: #22c55e; font: 600 var(--text-base) var(--font-mono); }
.dock__art--download { background: #e5e7eb; position: relative; }
.dock__art--download::before { content: "↓"; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #2563eb; font-size: var(--text-xl); font-weight: 700; }
.dock__divider { width: 1px; height: 44px; margin: 0 var(--space-1) var(--space-1); background: var(--dock-border); }
.scroll-cue { position: absolute; right: var(--space-6); bottom: var(--space-6); z-index: 15; font-size: var(--text-xs); color: #fff; padding: var(--space-1) var(--space-3); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.32); }
.scroll-cue:hover { color: #fff; background: rgba(0, 0, 0, 0.48); }
/* ── 菜单栏详情弹窗与主窗口(对应应用内的 PopoverRootView / MainWindowView) ── */
.os-popover,
.os-window {
position: absolute;
z-index: 30;
color: var(--p-text);
background: var(--p-bg);
border: 1px solid var(--p-border);
box-shadow: var(--shadow-2);
font-size: var(--text-sm);
}
.os-popover[hidden], .os-window[hidden] { display: none; }
.os-popover {
top: calc(var(--mb-height) + var(--space-1));
width: 320px;
max-height: calc(100vh - var(--mb-height) - var(--space-4));
display: flex;
flex-direction: column;
border-radius: var(--radius-xl);
overflow: hidden;
}
.op-pophead { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); color: var(--p-text-2); }
.op-pophead b { color: var(--p-text); font-size: var(--text-base); font-weight: 600; margin-right: auto; }
.op-popbody { min-height: 0; padding: 0 var(--space-3) var(--space-3); display: grid; gap: var(--space-3); align-content: start; overflow-y: auto; overscroll-behavior: contain; }
.os-window {
z-index: 28;
top: calc(var(--mb-height) + var(--space-8));
left: 50%;
transform: translateX(-50%);
width: min(960px, calc(100vw - var(--space-12)));
height: min(640px, calc(100vh - var(--mb-height) - 140px));
display: flex;
border-radius: var(--radius-lg);
overflow: hidden;
resize: both;
min-width: 640px;
min-height: 420px;
}
/* 侧边栏与页面在同一张底色上,不分栏着色,也没有分隔线 */
.ow-sidebar { flex: 0 0 192px; display: flex; flex-direction: column; padding: 0 var(--space-3) var(--space-3); }
.ow-lights { height: 40px; display: flex; align-items: center; gap: var(--space-2); padding-left: var(--space-1); cursor: grab; }
.ow-lights i, .ow-lights button { width: 12px; height: 12px; padding: 0; border: 0; border-radius: 50%; background: #febc2e; }
.ow-lights button { background: #ff5f57; cursor: pointer; }
.ow-lights i:last-child { background: #28c840; }
.ow-navlist { display: grid; gap: var(--space-1); align-content: start; margin-top: var(--space-2); }
.ow-group { padding: var(--space-2) var(--space-2) 0; font-size: var(--text-xs); font-weight: 500; color: var(--p-text-3); }
.ow-nav { display: flex; align-items: center; gap: var(--space-2); height: 32px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--p-text); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; }
.ow-nav:hover { background: var(--p-hover); }
.ow-nav[aria-current="page"] { background: var(--p-primary); color: #fff; font-weight: 600; }
.ow-tools { margin-top: auto; display: flex; gap: var(--space-1); }
.ow-tools .op-icon:last-child { margin-left: auto; }
.ow-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.ow-header { flex: none; height: 40px; display: flex; align-items: center; gap: var(--space-3); padding: 0 var(--space-4); cursor: grab; }
.ow-header h2 { margin-right: auto; font-size: var(--text-base); }
.ow-toggle-label { font-size: var(--text-xs); color: var(--p-text-2); }
.ow-content { flex: 1; min-height: 0; overflow-y: auto; padding: 0 var(--space-4) var(--space-4) var(--space-3); display: grid; gap: var(--space-3); align-content: start; }
/* ── 卡片与控件 ── */
.op-row { display: grid; gap: var(--space-3); align-items: stretch; }
.op-row--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.op-row--2-1 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.op-row--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
.op-card { padding: var(--space-3); background: var(--p-surface); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
.op-card__head { display: flex; align-items: center; gap: var(--space-1); min-height: 24px; font-size: var(--text-xs); font-weight: 600; color: var(--p-text-2); }
.op-card__head .op-chip, .op-card__note, .op-mini { margin-left: auto; }
.op-card__note { font-weight: 400; color: var(--p-text-3); }
.op-chip { display: inline-flex; align-items: center; gap: var(--space-1); font-size: var(--text-xs); font-weight: 500; padding: 0 var(--space-2); border-radius: var(--radius-sm); background: var(--p-track); color: var(--p-text-2); white-space: nowrap; font-variant-numeric: tabular-nums; }
.op-chip--primary { background: color-mix(in srgb, var(--p-primary) 14%, transparent); color: var(--p-primary); }
.op-chip--success { background: color-mix(in srgb, var(--p-success) 14%, transparent); color: var(--p-success); }
.op-chip--warning { background: color-mix(in srgb, var(--p-warning) 14%, transparent); color: var(--p-warning); }
.op-badge { display: inline-flex; align-items: center; gap: var(--space-1); padding: 0 var(--space-2); border-radius: var(--radius-sm); font-size: var(--text-xs); font-weight: 500; white-space: nowrap; }
.op-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.op-badge--success { color: var(--p-success); background: color-mix(in srgb, var(--p-success) 12%, transparent); }
.op-value { font-size: var(--text-xl); font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.25; white-space: nowrap; }
.op-value small { font-size: var(--text-xs); font-weight: 500; color: var(--p-text-2); margin-left: 2px; }
.op-foot { font-size: var(--text-xs); color: var(--p-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.op-chart { height: 40px; width: 100%; display: block; }
.op-chart--tall { height: 72px; }
.ow-content .op-chart { height: 48px; }
.ow-content .op-card > .op-chart, .ow-content .op-mirror .op-chart { height: 96px; }
.op-icon { width: 32px; height: 32px; flex: none; display: flex; align-items: center; justify-content: center; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--p-text-2); cursor: pointer; }
.op-icon:hover { background: var(--p-hover); color: var(--p-primary); }
.op-mini { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--p-text-2); cursor: pointer; }
.op-mini:hover { background: var(--p-hover); color: var(--p-text); }
.op-info { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); font-size: var(--text-xs); }
.op-info > span { color: var(--p-text-2); white-space: nowrap; display: inline-flex; align-items: center; gap: var(--space-2); }
.op-info > b { font-weight: 500; text-align: right; font-variant-numeric: tabular-nums; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.op-copy { max-width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.op-copy:hover { color: var(--p-primary); }
.op-copy.is-copied { color: var(--p-success); }
.op-flag { display: inline-flex; align-items: center; gap: var(--space-2); }
.op-flag img { border-radius: 2px; box-shadow: 0 0 0 1px var(--p-border); }
.op-swatch { width: 8px; height: 8px; border-radius: 2px; }
.op-hero { display: flex; align-items: center; gap: var(--space-3); position: relative; }
.op-hero__ring { position: absolute; left: 0; width: 56px; text-align: center; font-size: var(--text-sm); font-weight: 600; font-variant-numeric: tabular-nums; }
.op-hero__legend { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-1); margin-right: auto; }
.op-hero__legend .op-legend { margin-left: 0; }
.op-hero__legend .op-value { font-size: var(--text-lg); }
.op-rates { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.op-rates > div + div { padding-left: var(--space-3); border-left: 1px solid var(--p-border); }
.op-rates .op-legend { margin-left: 0; }
.op-mirror { display: grid; gap: var(--space-1); font-size: var(--text-xs); color: var(--p-text-3); font-variant-numeric: tabular-nums; }
.op-probe { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 2px; }
.op-probe--wide { grid-template-columns: repeat(40, minmax(0, 1fr)); }
.op-probe i { aspect-ratio: 1; border-radius: 2px; background: var(--p-track); }
.op-probe i.is-ok { background: var(--p-success); }
.op-probe i.is-slow { background: var(--p-warning); }
.op-probe i.is-fail { background: var(--p-error); }
.op-stats { display: grid; grid-template-columns: repeat(3, 1fr); }
.op-stats small { display: block; font-size: var(--text-xs); color: var(--p-text-3); }
.op-stats b { font-weight: 600; font-variant-numeric: tabular-nums; }
.op-chipgrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-1); }
.op-chipbtn { height: 24px; padding: 0 var(--space-1); border: 1px solid var(--p-border); border-radius: var(--radius-sm); background: transparent; color: var(--p-text-2); font: inherit; font-size: var(--text-xs); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.op-chipbtn:hover { background: var(--p-hover); }
.op-chipbtn[aria-pressed="true"] { border-color: color-mix(in srgb, var(--p-primary) 40%, transparent); background: color-mix(in srgb, var(--p-primary) 12%, transparent); color: var(--p-primary); font-weight: 600; }
.op-field { display: flex; gap: var(--space-2); }
.op-field input { flex: 1; min-width: 0; height: 32px; padding: 0 var(--space-2); border: 1px solid var(--color-neutral-300); border-radius: var(--radius-md); background: var(--p-elevated); color: var(--p-text); font: inherit; font-size: var(--text-xs); }
:root[data-theme="dark"] .op-field input { border-color: #3f434a; }
.op-field input:focus { outline: none; border-color: var(--p-primary); }
.op-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.op-note { font-size: var(--text-xs); color: var(--p-success); }
.op-health { display: flex; align-items: center; gap: var(--space-3); padding: 0 var(--space-1); }
.op-health__score { font-size: var(--text-2xl); font-weight: 600; font-variant-numeric: tabular-nums; }
.op-health__text { font-size: var(--text-base); color: var(--p-text-2); font-weight: 500; }
.op-badges { display: flex; flex-wrap: wrap; gap: var(--space-2); padding: 0 var(--space-1); }
.op-track { height: 8px; border-radius: var(--radius-sm); background: var(--p-track); overflow: hidden; }
.op-track--thin { height: 4px; margin-top: calc(-1 * var(--space-1)); }
.op-track i { display: block; height: 100%; border-radius: var(--radius-sm); background: var(--p-primary); transition: width 0.5s ease; }
.op-stack { display: flex; gap: 1px; height: 12px; border-radius: var(--radius-sm); background: var(--p-track); overflow: hidden; }
.op-stack i { display: block; height: 100%; }
.op-cores { display: flex; gap: 2px; height: 64px; align-items: flex-end; }
.op-cores i { flex: 1; height: 100%; border-radius: 2px; background: var(--p-track); position: relative; overflow: hidden; }
.op-cores i b { position: absolute; left: 0; right: 0; bottom: 0; background: var(--p-primary); transition: height 0.5s ease; }
.op-cores .gap { flex: 0 0 var(--space-3); background: none; }
.op-core-labels { display: flex; font-size: var(--text-xs); color: var(--p-text-3); }
.op-core-labels span:first-child { flex: 6 6 0; }
.op-core-labels span:last-child { flex: 12 12 0; margin-left: var(--space-3); }
.op-legend { display: inline-flex; align-items: center; gap: var(--space-1); font-size: var(--text-xs); color: var(--p-text-2); margin-left: var(--space-2); white-space: nowrap; }
.op-legend i { width: 8px; height: 8px; border-radius: 50%; }
.op-legend b { color: var(--p-text); font-weight: 600; }
.op-battery { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
.op-procs { display: grid; gap: var(--space-2); }
.op-proc { display: grid; align-items: center; gap: var(--space-2); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.op-proc--1 { grid-template-columns: 16px minmax(0, 1fr) 64px; }
.op-proc--2 { grid-template-columns: 16px minmax(0, 1fr) 64px 64px; }
.op-proc__icon { width: 16px; height: 16px; border-radius: var(--radius-sm); }
.op-proc__name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.op-proc__num { text-align: right; color: var(--p-text-2); }
.op-quick { display: flex; align-items: center; gap: var(--space-2); height: 32px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: var(--p-track); color: var(--p-text); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; }
.op-quick span:last-child { margin-left: auto; font-size: var(--text-xs); color: var(--p-text-3); font-weight: 400; }
.op-quick[aria-pressed="true"] { background: color-mix(in srgb, var(--p-primary) 12%, transparent); color: var(--p-primary); }
.op-quick[aria-pressed="true"] span:last-child { color: var(--p-primary); }
.op-list-row { display: flex; align-items: center; gap: var(--space-3); }
.op-list-row__title { width: 48px; font-weight: 500; }
.op-list-row .op-track { flex: 1; }
.op-list-row__value { width: 56px; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }
.op-list-row .op-foot { margin-right: auto; }
.op-toggle { position: relative; width: 40px; height: 24px; border: 0; border-radius: 12px; background: var(--color-neutral-300); cursor: pointer; flex: none; }
:root[data-theme="dark"] .op-toggle { background: #3f434a; }
.op-toggle::after { content: ""; position: absolute; top: 4px; left: 4px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: var(--shadow-1); transition: transform 0.15s ease; }
:root[data-theme="dark"] .op-toggle[aria-checked="true"], .op-toggle[aria-checked="true"] { background: var(--p-primary); }
.op-toggle[aria-checked="true"]::after { transform: translateX(16px); }
.op-seg { display: flex; padding: var(--space-1); background: var(--p-track); border-radius: var(--radius-md); }
.op-seg--narrow { width: 200px; }
.op-seg button { flex: 1; height: 24px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--p-text-2); font: inherit; font-size: var(--text-xs); cursor: pointer; }
.op-seg button[aria-pressed="true"] { background: var(--p-elevated); color: var(--p-text); font-weight: 600; box-shadow: var(--shadow-1); }
.op-radio { display: flex; gap: var(--space-3); align-items: flex-start; padding: var(--space-2) 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.op-radio i { width: 16px; height: 16px; margin-top: 2px; border-radius: 50%; border: 1.5px solid var(--color-neutral-300); flex: none; }
.op-radio[aria-checked="true"] i { border-color: var(--p-primary); box-shadow: inset 0 0 0 3px var(--p-surface), inset 0 0 0 8px var(--p-primary); }
.op-radio b { display: block; font-weight: 500; }
.op-radio small { display: block; font-size: var(--text-xs); color: var(--p-text-2); }
.op-banner { display: flex; gap: var(--space-2); padding: var(--space-3); border-radius: var(--radius-md); font-size: var(--text-xs); background: color-mix(in srgb, var(--p-warning) 10%, transparent); }
.op-banner svg { flex: none; color: var(--p-warning); }
.op-banner--success { background: color-mix(in srgb, var(--p-success) 10%, transparent); }
.op-banner--success svg { color: var(--p-success); }
.op-btn { align-self: flex-start; height: 32px; padding: 0 var(--space-3); border-radius: var(--radius-md); border: 1px solid var(--p-border); background: var(--p-elevated); color: var(--p-text); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; }
.op-btn:hover { background: var(--p-hover); }
.op-btn:disabled { opacity: 0.6; cursor: default; }
.op-btn--primary { border-color: transparent; background: var(--p-primary); color: #fff; }
.op-btn--primary:hover { background: var(--color-primary-hover); }
.op-check { width: 16px; height: 16px; border-radius: var(--radius-sm); border: 1.5px solid var(--color-neutral-300); background: transparent; cursor: pointer; flex: none; padding: 0; position: relative; }
.op-check[aria-checked="true"] { border-color: var(--p-primary); background: var(--p-primary); }
.op-check[aria-checked="true"]::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
.op-clean-row { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) 0; border-top: 1px solid var(--p-border); }
.op-card__head + .op-clean-row { border-top: 0; }
.op-clean-row b { display: block; font-weight: 500; }
.op-clean-row small { display: block; font-size: var(--text-xs); color: var(--p-text-3); }
.op-clean-row .size { margin-left: auto; font-weight: 500; font-variant-numeric: tabular-nums; }
/* ── 图表与状态色(SVG 与 JS 生成的元素使用) ── */
.fill-primary { fill: var(--p-primary); }
.fill-warning { fill: var(--p-warning); }
.fill-success { fill: var(--p-success); }
.fill-error { fill: var(--p-error); }
.fill-track { fill: var(--p-track); }
.stroke-white { fill: none; stroke: #fff; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.area-primary { fill: var(--p-primary); fill-opacity: 0.14; }
.stroke-primary { fill: none; stroke: var(--p-primary); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.area-secondary { fill: var(--p-secondary); fill-opacity: 0.14; }
.stroke-secondary { fill: none; stroke: var(--p-secondary); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.area-up { fill: var(--p-up); fill-opacity: 0.12; }
.stroke-up { fill: none; stroke: var(--p-up); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.area-down { fill: var(--p-down); fill-opacity: 0.12; }
.stroke-down { fill: none; stroke: var(--p-down); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.ring-track { stroke: var(--p-track); }
.ring-primary { stroke: var(--p-primary); }
.ring-success { stroke: var(--p-success); }
.ring-warning { stroke: var(--p-warning); }
.ring-error { stroke: var(--p-error); }
.bg-primary { background: var(--p-primary); }
.bg-secondary, .op-track i.bg-secondary { background: var(--p-secondary); }
.bg-warning, .op-track i.bg-warning { background: var(--p-warning); }
.bg-up { background: var(--p-up); }
.bg-down { background: var(--p-down); }
.bg-track { background: var(--p-track); }
.bg-muted { background: var(--color-neutral-300); }
.up-text { color: var(--p-up); }
.down-text { color: var(--p-down); }
.warning-text { color: var(--p-warning); }
.op-chart--center { display: flex; align-items: center; }
.op-chart--center .op-track { flex: 1; height: 12px; }
.op-quick svg { flex: none; }
/* 进程图标色块(示意,不复刻真实应用图标) */
.proc-c1 { background: #2563eb; }
.proc-c2 { background: #0d9488; }
.proc-c3 { background: #374151; }
.proc-c4 { background: #dc2626; }
.proc-c5 { background: #d97706; }
.proc-c6 { background: #6b7280; }
.mb-stats--static { cursor: default; background: transparent; }
.mb-stats--static:hover { background: transparent; }
/* ── 窄屏 ── */
@media (max-width: 1100px) {
.hero { transform: translateX(-50%); top: 16%; }
.desk-icons { display: none; }
.os-window { min-width: 0; }
.ow-sidebar { flex-basis: 64px; padding: 0 var(--space-2) var(--space-2); }
.ow-nav span, .ow-group, .ow-tools .op-icon:not(:first-child) { display: none; }
.ow-nav { justify-content: center; }
.op-row--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.mb-menu:not(:first-of-type), .menubar__extra, .menubar__clock { display: none; }
.menubar { white-space: nowrap; padding: 0 var(--space-2); }
.menubar__right { gap: var(--space-2); }
.island { display: none; }
.hero { top: calc(var(--mb-height) + var(--space-6)); }
.hero__body { padding: var(--space-6); }
.hero__title { font-size: var(--text-xl); }
.dock__item, .dock__art { width: 40px; height: 40px; }
.dock__art--terminal::before { font-size: var(--text-xs); left: 8px; top: 10px; }
.dock__art--fan::before { inset: 10px; border-width: 3px; }
.dock__art--clean::before { left: 13px; right: 13px; top: 10px; bottom: 9px; }
.scroll-cue { display: none; }
.os-window { top: calc(var(--mb-height) + var(--space-2)); width: calc(100vw - var(--space-4)); height: calc(100vh - var(--mb-height) - 96px); resize: none; }
.op-row--3, .op-row--2, .op-row--2-1 { grid-template-columns: 1fr; }
.op-probe--wide { grid-template-columns: repeat(20, minmax(0, 1fr)); }
.op-probe--wide i:nth-child(-n+60) { display: none; }
}
+768
View File
@@ -0,0 +1,768 @@
/* OpenStats 官网 · 应用界面还原
菜单栏读数、详情弹窗、主窗口与刘海岛都是 HTML 重建,数据是本页随机游走生成的模拟值,
不读取访客电脑的任何信息。风格、字号与布局对应应用内的 MenuBarRenderer、详情弹窗与主窗口。 */
(() => {
"use strict";
// ── 模拟数据 ──────────────────────────────────────────────
const HISTORY = 30;
const PROBES = 120;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const walk = (v, step, lo, hi) => clamp(v + (Math.random() - 0.5) * step, lo, hi);
const fill = (n, f) => Array.from({ length: n }, f);
const ITEM_ORDER = ["cpu", "mem", "net", "gpu", "temp"];
const S = {
cpu: 0.24, cpuHistory: fill(HISTORY, () => 0.15 + Math.random() * 0.2),
cores: fill(18, (_, i) => (i < 6 ? 0.1 : 0.35) + Math.random() * 0.2),
gpu: 0.12, gpuHistory: fill(HISTORY, () => Math.random() * 0.25),
mem: 0.68, memHistory: fill(HISTORY, () => 0.68),
up: 12 * 1024, down: 1.4 * 1024 * 1024,
upHistory: fill(HISTORY, () => Math.random() * 40 * 1024),
downHistory: fill(HISTORY, () => Math.random() * 1.6 * 1024 * 1024),
probes: fill(36, () => 58 + Math.random() * 12),
cpuTemp: 52, gpuTemp: 47, memTemp: 45, batteryTemp: 31, palmTemp: 30,
fans: [1640, 1760], fanMode: "auto",
keepAwake: false, awakeMode: "display", lidClosed: false, duration: 0,
items: readList("os-items", ["cpu", "mem", "net"]),
popover: null,
page: "overview", windowOpen: false, sort: "cpu",
dns: "auto", dnsManual: false, dnsNote: "",
style: read("os-style", "stacked"),
cleaned: false, cleaning: false, confirming: false,
};
const FAN_RANGE = [[1350, 5349], [1350, 5777]];
const PROCESSES = [
["Safari", 1], ["Xcode", 1], ["WindowServer", 3], ["音乐", 4], ["访达", 1], ["OpenStats", 1],
["终端", 3], ["照片", 5], ["备忘录", 5], ["邮件", 2], ["Spotlight", 6], ["系统设置", 6],
].map(([name, color]) => ({ name, color, cpu: Math.random() * 0.3, mem: (80 + Math.random() * 900) * 1024 * 1024 }));
const NET_PROCESSES = [["Safari", 1], ["音乐", 4], ["邮件", 2], ["终端", 3], ["照片", 5]]
.map(([name, color]) => ({ name, color, down: Math.random() * 400 * 1024, up: Math.random() * 60 * 1024 }));
const CLEAN_RULES = [
{ id: "caches", cat: "系统", title: "应用缓存", detail: "10 项 · 8 项使用中", size: 3.5e9, on: true },
{ id: "logs", cat: "系统", title: "应用日志与崩溃报告", detail: "10 项", size: 1.9e6, on: true },
{ id: "chrome", cat: "浏览器", title: "Chrome 缓存", detail: "Chrome 正在运行", size: 0, blocked: true },
{ id: "safari", cat: "浏览器", title: "Safari 缓存", detail: "1 项", size: 812e6, on: true },
{ id: "derived", cat: "开发者", title: "Xcode 编译缓存", detail: "6 项", size: 6.2e9, on: true },
{ id: "npm", cat: "开发者", title: "npm 缓存", detail: "1 项", size: 11.8e9, on: true },
{ id: "installers", cat: "下载", title: "安装包", detail: "3 项 · 移到废纸篓", size: 1.2e9, on: false },
{ id: "trash", cat: "废纸篓", title: "清空废纸篓", detail: "永久删除", size: 640e6, on: false },
];
const DNS_PRESETS = [
["auto", "自动", []], ["cloudflare", "Cloudflare", ["1.1.1.1", "1.0.0.1"]], ["google", "Google", ["8.8.8.8", "8.8.4.4"]],
["tencent", "腾讯 DNSPod", ["119.29.29.29", "182.254.116.116"], "腾讯"], ["aliyun", "阿里云", ["223.5.5.5", "223.6.6.6"]],
];
function read(key, fallback) {
try { return localStorage.getItem(key) || fallback; } catch { return fallback; }
}
function readList(key, fallback) {
const value = read(key, "");
const list = value.split(",").filter((k) => ITEM_ORDER.includes(k));
return value ? list : fallback;
}
function write(key, value) {
try { localStorage.setItem(key, value); } catch { /* 隐私模式下不可用,忽略 */ }
}
function push(history, value, size = HISTORY) {
history.push(value);
if (history.length > size) history.shift();
}
function tick() {
S.cores = S.cores.map((v, i) => walk(v, 0.18, 0.02, i < 6 ? 0.55 : 0.9));
S.cpu = S.cores.reduce((a, b) => a + b, 0) / S.cores.length;
S.gpu = walk(S.gpu, 0.12, 0, 0.6);
S.mem = walk(S.mem, 0.01, 0.6, 0.78);
S.up = clamp(S.up * (0.6 + Math.random() * 0.8) + Math.random() * 8000, 800, 4 * 1024 * 1024);
S.down = clamp(S.down * (0.6 + Math.random() * 0.8) + Math.random() * 60000, 2000, 60 * 1024 * 1024);
S.cpuTemp = walk(S.cpuTemp, 2, 42, 78);
S.gpuTemp = walk(S.gpuTemp, 1.5, 40, 70);
push(S.cpuHistory, S.cpu); push(S.gpuHistory, S.gpu); push(S.memHistory, S.mem);
push(S.upHistory, S.up); push(S.downHistory, S.down);
// 探测:偶尔一次偏慢或超时
const roll = Math.random();
push(S.probes, roll < 0.02 ? null : roll < 0.06 ? 220 + Math.random() * 80 : 58 + Math.random() * 12, PROBES);
const targets = { auto: [0.07 + S.cpu * 0.3, 0.08 + S.cpu * 0.3], cool: [0.6, 0.6], max: [1, 1], custom: [0.45, 0.45] }[S.fanMode];
S.fans = S.fans.map((rpm, i) => {
const [lo, hi] = FAN_RANGE[i];
const target = lo + (hi - lo) * targets[i];
return Math.round(rpm + (target - rpm) * 0.35);
});
PROCESSES.forEach((p) => { p.cpu = clamp(p.cpu + (Math.random() - 0.5) * 0.2, 0, 1.2); });
NET_PROCESSES.forEach((p) => {
p.down = clamp(p.down * (0.5 + Math.random()), 0, 8 * 1024 * 1024);
p.up = clamp(p.up * (0.5 + Math.random()), 0, 1024 * 1024);
});
}
// ── 格式化 ────────────────────────────────────────────────
const pct = (v) => `${Math.round(clamp(v, 0, 1) * 100)}%`;
function rate(bps) {
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = Math.max(0, bps), i = 0;
while (value >= 999.5 && i < units.length - 1) { value /= 1024; i++; }
const text = i >= 2 && value < 9.95 ? value.toFixed(1) : String(Math.round(value));
return `${text} ${units[i]}`;
}
function bytes(n, decimal = true) {
const step = decimal ? 1000 : 1024;
const units = ["B", "KB", "MB", "GB", "TB"];
let value = n, i = 0;
while (value >= step && i < units.length - 1) { value /= step; i++; }
return i === 0 ? `${Math.round(value)} B` : `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[i]}`;
}
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const splitRate = (bps) => { const [n, u] = rate(bps).split(" "); return `${n}<small>${u}</small>`; };
// ── SVG 图形 ──────────────────────────────────────────────
function barsSVG(values, { w = 190, h = 40, count = 20, cls = "fill-primary" } = {}) {
const recent = values.slice(-count);
const gap = 2, bw = (w - gap * (count - 1)) / count;
let out = "";
for (let i = 0; i < count; i++) {
const v = recent[i - (count - recent.length)];
const x = i * (bw + gap);
if (v === undefined) { out += `<rect class="fill-track" x="${x}" y="${h - 2}" width="${bw}" height="2"/>`; continue; }
const bh = Math.max(2, h * clamp(v, 0, 1));
out += `<rect class="${v >= 0.8 ? "fill-warning" : cls}" x="${x}" y="${h - bh}" width="${bw}" height="${bh}" rx="1"/>`;
}
return `<svg class="op-chart" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">${out}</svg>`;
}
function points(values, w, top, span, max, invert = false) {
const step = w / (HISTORY - 1);
return values.map((v, i) => {
const x = w - (values.length - 1 - i) * step;
const offset = span * clamp(v / max, 0, 1);
return [x, invert ? top + offset : top + span - offset];
});
}
const pathOf = (pts) => pts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`).join("");
function lineSVG(values, { w = 190, h = 40, max = 1, cls = "primary" } = {}) {
const d = pathOf(points(values, w, 1, h - 2, max));
return `<svg class="op-chart" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<path class="area-${cls}" d="${d}L${w},${h}L0,${h}Z"/><path class="stroke-${cls}" d="${d}"/></svg>`;
}
function dualSVG(up, down, { w = 190, h = 40 } = {}) {
const max = Math.max(...up, ...down, 64 * 1024) * 1.1;
const du = pathOf(points(up, w, 1, h - 2, max)), dd = pathOf(points(down, w, 1, h - 2, max));
return `<svg class="op-chart" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<path class="area-down" d="${dd}L${w},${h}L0,${h}Z"/><path class="stroke-down" d="${dd}"/>
<path class="area-up" d="${du}L${w},${h}L0,${h}Z"/><path class="stroke-up" d="${du}"/></svg>`;
}
/** 上下镜像:上半上传、下半下载,各自按峰值缩放 */
function mirroredSVG(up, down, { w = 296, h = 72 } = {}) {
const mid = h / 2;
const du = pathOf(points(up, w, 1, mid - 1, Math.max(...up, 64 * 1024)));
const dd = pathOf(points(down, w, mid, mid - 1, Math.max(...down, 64 * 1024), true));
const first = w - (up.length - 1) * (w / (HISTORY - 1));
return `<svg class="op-chart op-chart--tall" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<path class="area-up" d="${du}L${w},${mid}L${first},${mid}Z"/><path class="stroke-up" d="${du}"/>
<path class="area-down" d="${dd}L${w},${mid}L${first},${mid}Z"/><path class="stroke-down" d="${dd}"/>
<rect class="fill-track" x="0" y="${mid - 0.5}" width="${w}" height="1"/></svg>`;
}
function ringSVG(fraction, size, width, cls) {
const r = (size - width) / 2, c = 2 * Math.PI * r;
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<circle class="ring-track" cx="${size / 2}" cy="${size / 2}" r="${r}" stroke-width="${width}" fill="none"/>
<circle class="${cls}" cx="${size / 2}" cy="${size / 2}" r="${r}" stroke-width="${width}" fill="none" stroke-linecap="round"
stroke-dasharray="${(c * clamp(fraction, 0, 1)).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 ${size / 2} ${size / 2})"/></svg>`;
}
// ── 图标(线性 SVG,不使用 emoji) ──────────────────────────
const ICON = {
cpu: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4" y="4" width="8" height="8" rx="1.5"/><path d="M6 1.5v2.5M10 1.5v2.5M6 12v2.5M10 12v2.5M1.5 6H4M1.5 10H4M12 6h2.5M12 10h2.5"/></svg>',
mem: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="4" width="13" height="6.5" rx="1.2"/><path d="M4.5 10.5v2.5M8 10.5v2.5M11.5 10.5v2.5"/></svg>',
net: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><path d="M5 13V3M2.5 5.5 5 3l2.5 2.5M11 3v10M8.5 10.5 11 13l2.5-2.5"/></svg>',
gpu: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"><path d="M8 1.8 14.2 5 8 8.2 1.8 5z"/><path d="M1.8 8 8 11.2 14.2 8M1.8 11 8 14.2l6.2-3.2"/></svg>',
temp: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M6.5 9.3V3a1.5 1.5 0 0 1 3 0v6.3a3 3 0 1 1-3 0z"/><circle cx="8" cy="11.8" r="1" fill="currentColor"/></svg>',
dash: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></svg>',
list: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M4.5 6h7M4.5 8.5h7M4.5 11h4"/></svg>',
cup: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><path d="M2.5 6h9v3.5a3.5 3.5 0 0 1-3.5 3.5H6A3.5 3.5 0 0 1 2.5 9.5z"/><path d="M11.5 7h1a1.5 1.5 0 0 1 0 3h-1M5 1.8v2M8 1.8v2"/></svg>',
laptop: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="3" y="3.5" width="10" height="7" rx="1"/><path d="M1.5 12.5h13" stroke-linecap="round"/></svg>',
fan: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><circle cx="8" cy="8" r="1.5"/><path d="M8 6.5C8 3 9.5 1.8 11 2.6s1 3.4-3 3.9M9.5 8c3.5 0 4.7 1.5 3.9 3s-3.4 1-3.9-3M8 9.5c0 3.5-1.5 4.7-3 3.9s-1-3.4 3-3.9M6.5 8C3 8 1.8 6.5 2.6 5s3.4-1 3.9 3"/></svg>',
clean: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"><path d="M8 1.5 9.3 6.7 14.5 8 9.3 9.3 8 14.5 6.7 9.3 1.5 8l5.2-1.3z"/></svg>',
window: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><path d="M1.5 5.5h13"/></svg>',
gear: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><circle cx="8" cy="8" r="2.3"/><path d="M8 1.5v2M8 12.5v2M14.5 8h-2M3.5 8h-2M12.6 3.4l-1.4 1.4M4.8 11.2l-1.4 1.4M12.6 12.6l-1.4-1.4M4.8 4.8 3.4 3.4"/></svg>',
moon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M13.5 9.8A6 6 0 1 1 6.2 2.5a4.8 4.8 0 0 0 7.3 7.3z"/></svg>',
power: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><path d="M8 1.8v5.4M4.4 3.8a5.4 5.4 0 1 0 7.2 0"/></svg>',
refresh: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><path d="M12 7a5 5 0 1 1-1.5-3.6M12 1.5v2.5H9.5"/></svg>',
warn: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"><path d="M8 2 14.5 13.5h-13z"/><path d="M8 6.5v3M8 11.5v.1" stroke-linecap="round"/></svg>',
check: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m3.5 8.5 3 3 6-7"/></svg>',
apple: '<svg width="9" height="11" viewBox="0 0 14 17" fill="currentColor" aria-hidden="true"><path d="M11.5 9c0-1.9 1.5-2.8 1.6-2.9-.9-1.3-2.2-1.5-2.7-1.5-1.1-.1-2.2.7-2.8.7-.6 0-1.5-.7-2.4-.6-1.2 0-2.4.7-3 1.8-1.3 2.2-.3 5.4 1 7.2.6.8 1.3 1.8 2.2 1.7.9 0 1.2-.6 2.2-.6 1 0 1.3.6 2.2.5.9 0 1.5-.8 2.1-1.7.7-1 .9-1.9.9-1.9s-1.8-.7-1.8-2.7zM9.7 3.2c.5-.6.8-1.4.7-2.2-.7 0-1.6.5-2.1 1.1-.5.6-.9 1.4-.8 2.2.8.1 1.7-.4 2.2-1.1z"/></svg>',
};
// ── 菜单栏读数:八种风格 ────────────────────────────────────
const STYLES = [
["stacked", "双行文字", "小标签在上、数值在下,最紧凑"],
["inline", "单行文字", "标签与数值同一行,最易读"],
["icon", "图标", "用系统图标代替文字标签"],
["ring", "圆环", "圆环表示当前占用比例"],
["pie", "饼图", "饼图表示当前占用比例"],
["history", "柱状历史", "10 根柱子是最近 10 次采样(约 20 秒)的变化"],
["meter", "电量条", "竖向电量条表示当前占用比例"],
["dot", "状态圆点", "绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)"],
];
const MINI = {
cpu: '<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1"><rect x="2.5" y="2.5" width="6" height="6" rx="1"/><path d="M4 .5v2M7 .5v2M4 8.5v2M7 8.5v2M.5 4h2M.5 7h2M8.5 4h2M8.5 7h2"/></svg>',
mem: '<svg width="13" height="9" viewBox="0 0 13 9" fill="none" stroke="currentColor" stroke-width="1"><rect x=".5" y="1.5" width="12" height="5" rx="1"/><path d="M3 6.5v2M6.5 6.5v2M10 6.5v2"/></svg>',
gpu: '<svg width="12" height="11" viewBox="0 0 12 11" fill="none" stroke="currentColor" stroke-width="1"><path d="M6 .8 11.2 3.4 6 6 .8 3.4z"/><path d="M.8 5.6 6 8.2l5.2-2.6M.8 7.8 6 10.4l5.2-2.6"/></svg>',
temp: '<svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1"><path d="M2 7V1.8a1.5 1.5 0 0 1 3 0V7a2.6 2.6 0 1 1-3 0z"/></svg>',
};
function shape(style, value, history) {
const f = clamp(value, 0, 1);
switch (style) {
case "ring": {
const r = 5.5, c = 2 * Math.PI * r;
return `<svg width="13" height="13" viewBox="0 0 13 13"><circle cx="6.5" cy="6.5" r="${r}" fill="none" stroke="currentColor" stroke-opacity=".25" stroke-width="2"/><circle cx="6.5" cy="6.5" r="${r}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="${(c * f).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 6.5 6.5)"/></svg>`;
}
case "pie": {
const a = f * 2 * Math.PI, x = 6.5 + 6.5 * Math.sin(a), y = 6.5 - 6.5 * Math.cos(a);
const wedge = f >= 0.999 ? '<circle cx="6.5" cy="6.5" r="6.5" fill="currentColor"/>'
: `<path d="M6.5 6.5V0A6.5 6.5 0 ${f > 0.5 ? 1 : 0} 1 ${x.toFixed(2)} ${y.toFixed(2)}Z" fill="currentColor"/>`;
return `<svg width="13" height="13" viewBox="0 0 13 13"><circle cx="6.5" cy="6.5" r="6.5" fill="currentColor" fill-opacity=".25"/>${wedge}</svg>`;
}
case "history": {
const recent = history.slice(-10);
const bars = recent.map((v, i) => { const h = Math.max(1, 13 * clamp(v, 0, 1)); return `<rect x="${i * 3}" y="${13 - h}" width="2" height="${h}" fill="currentColor"/>`; }).join("");
return `<svg width="29" height="13" viewBox="0 0 29 13"><rect x="0" y="12" width="29" height="1" fill="currentColor" fill-opacity=".25"/>${bars}</svg>`;
}
case "meter": {
const h = Math.max(3.4, 13 * f);
return `<svg width="5" height="13" viewBox="0 0 5 13"><rect width="5" height="13" rx="1.7" fill="currentColor" fill-opacity=".25"/><rect y="${13 - h}" width="5" height="${h}" rx="1.7" fill="currentColor"/></svg>`;
}
case "dot": {
const cls = f >= 0.85 ? "fill-error" : f >= 0.6 ? "fill-warning" : "fill-success";
return `<svg width="5" height="5" viewBox="0 0 5 5"><circle class="${cls}" cx="2.5" cy="2.5" r="2.5"/></svg>`;
}
default: return "";
}
}
function metricHTML(key, label, value, history, style, text = pct(value)) {
const stacked = `<span class="mbs__stack"><span class="mbs__label">${label}</span><span class="mbs__value">${text}</span></span>`;
if (style === "stacked") return `<span class="mbs">${stacked}</span>`;
if (style === "inline") return `<span class="mbs"><span class="mbs__inline-label">${label}</span><span class="mbs__inline-value">${text}</span></span>`;
if (style === "icon") return `<span class="mbs">${MINI[key]}<span class="mbs__inline-value">${text}</span></span>`;
if (key === "temp") return `<span class="mbs">${stacked}</span>`;
return `<span class="mbs">${shape(style, value, history)}${stacked}</span>`;
}
function itemHTML(key, style) {
switch (key) {
case "cpu": return metricHTML("cpu", "CPU", S.cpu, S.cpuHistory, style);
case "mem": return metricHTML("mem", "RAM", S.mem, S.memHistory, style);
case "gpu": return metricHTML("gpu", "GPU", S.gpu, S.gpuHistory, style);
case "temp": return metricHTML("temp", "TEMP", 0, [], style, `${Math.round(S.cpuTemp)}°`);
case "net": return `<span class="mbs"><span class="mbs__net"><span><i class="up"></i>${rate(S.up)}</span><span><i class="down"></i>${rate(S.down)}</span></span></span>`;
default: return "";
}
}
// ── 共用片段 ──────────────────────────────────────────────
const card = (head, body, extra = "") => `<div class="op-card ${extra}"><div class="op-card__head">${head}</div>${body}</div>`;
const row = (label, value) => `<div class="op-info"><span>${label}</span><b>${value}</b></div>`;
const copyable = (text) => `<button class="op-copy" type="button" data-copy="${esc(text)}" title="点击拷贝">${esc(text)}</button>`;
const tempTone = (t) => (t >= 80 ? "op-chip--warning" : "op-chip--primary");
const level = (v) => (v < 0.3 ? "低负载" : v < 0.7 ? "中等负载" : "高负载");
const loadRing = (v) => (v >= 0.85 ? "ring-error" : v >= 0.6 ? "ring-warning" : "ring-primary");
function procRows(list, cols) {
return `<div class="op-procs">${list.map((p) => `<div class="op-proc op-proc--${cols.length}"><i class="op-proc__icon proc-c${p.color}"></i><span class="op-proc__name">${esc(p.name)}</span>${cols.map((c) => `<span class="op-proc__num">${c(p)}</span>`).join("")}</div>`).join("")}</div>`;
}
const cpuText = (p) => `${(p.cpu * 100).toFixed(1)}%`;
const memText = (p) => bytes(p.mem, false);
// ── 各指标内容(弹窗与主窗口共用,wide 为主窗口) ─────────────
const CONTENT = {
cpu(wide) {
const cores = S.cores.slice(12).concat(S.cores.slice(0, 12));
const coreBars = cores.map((v, i) => `${i === 6 ? '<i class="gap"></i>' : ""}<i><b style="height:${Math.round(v * 100)}%"></b></i>`).join("");
const hero = `<div class="op-card"><div class="op-hero">${ringSVG(S.cpu, 56, 6, loadRing(S.cpu))}<span class="op-hero__ring">${pct(S.cpu)}</span>
<div class="op-hero__legend"><span class="op-legend"><i class="bg-primary"></i>用户 <b>${Math.round(S.cpu * 72)}%</b></span><span class="op-legend"><i class="bg-secondary"></i>系统 <b>${Math.round(S.cpu * 28)}%</b></span><span class="op-legend"><i class="bg-track"></i>空闲 <b>${pct(1 - S.cpu)}</b></span></div>
<span class="op-chip ${tempTone(S.cpuTemp)}">${Math.round(S.cpuTemp)}°C</span></div></div>`;
const history = card(`负载历史 <span class="op-card__note">最近 60 秒</span>`, barsSVG(S.cpuHistory, { count: wide ? 30 : 24, w: wide ? 600 : 296, h: wide ? 96 : 48 }));
const coresCard = card("核心负载", `<div class="op-cores">${coreBars}</div><div class="op-core-labels"><span>超级核 · 6</span><span>性能核 · 12</span></div>`);
const info = card("处理器信息", row("处理器", "Apple M5 Max") + row("核心", "18 核(6 超级核 + 12 性能核)") + row("负载平均", `${(S.cpu * 18).toFixed(2)} · ${(S.cpu * 16).toFixed(2)} · ${(S.cpu * 15).toFixed(2)}`) + row("已运行", "3 天 4 小时"));
const procs = card(`高占用进程 <span class="op-card__note">CPU</span>`, procRows([...PROCESSES].sort((a, b) => b.cpu - a.cpu).slice(0, 5), [cpuText]));
return wide ? `${hero}${history}<div class="op-row op-row--2">${coresCard}${info}</div>${procs}` : hero + history + coresCard + info + procs;
},
mem(wide) {
const used = S.mem * 64;
const hero = `<div class="op-card"><div class="op-hero">${ringSVG(S.mem, 56, 6, "ring-success")}<span class="op-hero__ring">${pct(S.mem)}</span>
<div class="op-hero__legend"><span class="op-value">${used.toFixed(1)} GB<small>/ 64.0 GB</small></span><span class="op-badge op-badge--success">内存压力 正常</span></div></div></div>`;
const history = card(`使用历史 <span class="op-card__note">最近 60 秒</span>`, lineSVG(S.memHistory, { w: wide ? 600 : 296, h: wide ? 96 : 48 }));
const app = used * 0.78, wired = used * 0.14, comp = used * 0.08;
const parts = [["bg-primary", "App 内存", app], ["bg-secondary", "联动内存", wired], ["bg-warning", "被压缩", comp], ["bg-muted", "缓存文件", 19.2], ["bg-track", "交换空间", 0.01]];
const stack = `<div class="op-stack"><i class="bg-primary" style="width:${(app / 64 * 100).toFixed(1)}%"></i><i class="bg-secondary" style="width:${(wired / 64 * 100).toFixed(1)}%"></i><i class="bg-warning" style="width:${(comp / 64 * 100).toFixed(1)}%"></i></div>`;
const breakdown = card("内存构成", stack + parts.map(([cls, t, gb]) => `<div class="op-info"><span><i class="op-swatch ${cls}"></i>${t}</span><b>${gb < 1 ? "12.8 MB" : `${gb.toFixed(1)} GB`}</b></div>`).join("")
+ `<button class="op-btn" type="button" data-action="purge">释放内存</button>`);
const procs = card(`高占用进程 <span class="op-card__note">内存</span>`, procRows([...PROCESSES].sort((a, b) => b.mem - a.mem).slice(0, 5), [memText]));
return wide ? `${hero}${history}<div class="op-row op-row--2">${breakdown}${procs}</div>` : hero + history + breakdown + procs;
},
net(wide) {
const hero = `<div class="op-card"><div class="op-rates">
<div><div class="op-value">${splitRate(S.down)}</div><span class="op-legend"><i class="bg-down"></i>下载</span></div>
<div><div class="op-value">${splitRate(S.up)}</div><span class="op-legend"><i class="bg-up"></i>上传</span></div></div></div>`;
const history = card(`流量历史 <span class="op-card__note">最近 60 秒</span>`,
`<div class="op-mirror"><span>峰值 ↑ ${rate(Math.max(...S.upHistory, 64 * 1024))}</span>${mirroredSVG(S.upHistory, S.downHistory, { w: wide ? 600 : 296, h: wide ? 120 : 72 })}<span>峰值 ↓ ${rate(Math.max(...S.downHistory, 64 * 1024))}</span></div>`);
const count = wide ? 120 : 60;
const recent = S.probes.slice(-count);
const cells = fill(count - recent.length, () => '<i></i>').join("")
+ recent.map((v) => `<i class="${v == null ? "is-fail" : v >= 200 ? "is-slow" : "is-ok"}"></i>`).join("");
const ok = recent.filter((v) => v != null);
const jitter = ok.slice(-20).reduce((a, v, i, arr) => (i ? a + Math.abs(v - arr[i - 1]) : a), 0) / Math.max(1, Math.min(ok.length, 20) - 1);
const loss = recent.length ? recent.filter((v) => v == null).length / recent.length : 0;
const last = [...recent].reverse().find((v) => v != null);
const probe = card(`连接探测 <span class="op-card__note">1.1.1.1 · 每 2 秒</span>`,
`<div class="op-probe ${wide ? "op-probe--wide" : ""}">${cells}</div>
<div class="op-stats"><div><small>延迟</small><b>${last ? Math.round(last) : "—"} ms</b></div><div><small>抖动</small><b>${jitter.toFixed(1)} ms</b></div><div><small>丢包</small><b>${Math.round(loss * 100)}%</b></div></div>`);
const iface = card("接口", row("接口", "Wi‑Fi(en0)") + row("状态", '<span class="op-badge op-badge--success">已连接</span>')
+ row("物理地址", copyable("a4:83:e7:12:34:56")) + row("信号强度", "-42 dBm · 极好") + row("传输速率", "1200 Mbps")
+ row("开机后下载", "18.4 GB") + row("开机后上传", "2.1 GB"));
const addresses = card(`IP 地址 <button class="op-mini" type="button" data-action="lookup" aria-label="重新查询公网 IP">${ICON.refresh}</button>`,
row("本地 IPv4", copyable("192.168.1.20")) + row("本地 IPv6", copyable("2001:db8::20")) + row("路由器", copyable("192.168.1.1"))
+ row("公网 IPv4", copyable("203.0.113.24")) + row("公网 IPv6", copyable("2001:db8:85a3::8a2e"))
+ row("归属地", '<span class="op-flag"><img src="assets/flags/cn.svg" alt="" width="16" height="12">中国 · Shanghai</span>')
+ row("ASN", copyable("AS64500")) + row("网络运营方", "EXAMPLE NETWORK"));
const current = DNS_PRESETS.find(([id]) => id === S.dns);
const servers = S.dns === "auto" ? "192.168.1.1" : current[2].join("<br>");
const chips = DNS_PRESETS.map(([id, title, , short]) => `<button class="op-chipbtn" type="button" data-dns="${id}" aria-pressed="${!S.dnsManual && S.dns === id}" title="${title}">${short || title}</button>`).join("")
+ `<button class="op-chipbtn" type="button" data-action="dns-manual" aria-pressed="${S.dnsManual}">手动</button>`;
const manual = S.dnsManual ? `<form class="op-field" data-form="dns"><input name="servers" type="text" placeholder="例如 1.1.1.1, 8.8.8.8" aria-label="DNS 服务器" autocomplete="off"><button class="op-btn op-btn--primary" type="submit">应用</button></form>` : "";
const dns = card(`DNS <span class="op-card__note">Wi‑Fi</span>`,
row("正在使用", servers) + row("配置方式", S.dns === "auto" ? "自动(由路由器分配)" : current[1])
+ `<div class="op-chipgrid">${chips}</div>${manual}
<div class="op-actions"><button class="op-btn" type="button" data-action="flush">刷新 DNS 缓存</button></div>
${S.dnsNote ? `<div class="op-note">${esc(S.dnsNote)}</div>` : ""}`);
const procs = card(`高占用进程 <span class="op-card__note">下载 · 上传</span>`,
procRows([...NET_PROCESSES].sort((a, b) => b.down + b.up - a.down - a.up), [(p) => rate(p.down), (p) => rate(p.up)]));
return wide
? `${hero}<div class="op-row op-row--2">${history}${probe}</div><div class="op-row op-row--2">${iface}${addresses}</div><div class="op-row op-row--2">${dns}${procs}</div>`
: hero + history + probe + iface + addresses + dns + procs;
},
gpu(wide) {
const hero = `<div class="op-card"><div class="op-hero">${ringSVG(S.gpu, 56, 6, loadRing(S.gpu))}<span class="op-hero__ring">${pct(S.gpu)}</span>
<div class="op-hero__legend"><b>Apple M5 Max</b><span class="op-foot">40 核图形处理器</span></div>
<span class="op-chip ${tempTone(S.gpuTemp)}">${Math.round(S.gpuTemp)}°C</span></div></div>`;
return hero + card(`使用历史 <span class="op-card__note">最近 60 秒</span>`, lineSVG(S.gpuHistory, { cls: "secondary", w: wide ? 600 : 296, h: wide ? 96 : 48 }))
+ card("显卡信息", row("型号", "Apple M5 Max") + row("核心数", "40") + row("最高温度", `${Math.round(S.gpuTemp)}°C`));
},
temp(wide) {
if (wide) return thermalPage();
const temps = [["CPU", S.cpuTemp], ["GPU", S.gpuTemp], ["内存", S.memTemp], ["电池", S.batteryTemp], ["掌托", S.palmTemp]]
.map(([t, v]) => `<div class="op-info"><span>${t}</span><b>${Math.round(v)}°C</b></div><div class="op-track op-track--thin"><i class="${v >= 80 ? "bg-warning" : ""}" style="width:${Math.round(v / 110 * 100)}%"></i></div>`).join("");
const fans = S.fans.map((rpm, i) => `<div class="op-info"><span>风扇 ${i + 1}</span><b>${rpm.toLocaleString("en-US")} RPM</b></div><div class="op-track op-track--thin"><i style="width:${Math.round(rpm / FAN_RANGE[i][1] * 100)}%"></i></div>`).join("");
const modes = [["auto", "自动"], ["cool", "降温"], ["max", "强冷"]].map(([m, t]) => `<button class="op-chipbtn" type="button" data-fan="${m}" aria-pressed="${S.fanMode === m}">${t}</button>`).join("");
return `<div class="op-card"><div class="op-rates"><div><small class="op-foot">CPU 最高</small><div class="op-value">${Math.round(S.cpuTemp)}°C</div></div><div><small class="op-foot">风扇</small><div class="op-value">${Math.max(...S.fans).toLocaleString("en-US")}<small>RPM</small></div></div></div></div>`
+ card(`温度 <span class="op-card__note">最高</span>`, temps)
+ card(`风扇 <span class="op-card__note">${S.fanMode === "auto" ? "由 macOS 调节" : "OpenStats 控制中"}</span>`, `${fans}<div class="op-chipgrid op-chipgrid--3">${modes}</div>`);
},
};
const ITEM_META = {
cpu: ["CPU", ICON.cpu], mem: ["内存", ICON.mem], net: ["网络", ICON.net], gpu: ["GPU", ICON.gpu], temp: ["温度", ICON.temp],
};
// ── 菜单栏与详情弹窗 ───────────────────────────────────────
const itemsBox = document.getElementById("mbItems");
const popover = document.getElementById("osPopover");
/** 按钮保持不变,只替换里面的读数:刷新时整个按钮被替换会让恰好落在刷新瞬间的点击丢失 */
function renderMenubar() {
if (!itemsBox) return;
const items = ITEM_ORDER.filter((k) => S.items.includes(k));
const keys = items.length ? items : ["window"];
const current = [...itemsBox.children].map((b) => b.dataset.item || "window");
if (current.join() !== keys.join()) {
itemsBox.innerHTML = keys.map((key) => key === "window"
? `<button class="mb-stats" type="button" data-action="window" aria-label="打开 OpenStats"></button>`
: `<button class="mb-stats" type="button" data-item="${key}" aria-label="${ITEM_META[key][0]}详情" aria-haspopup="dialog"></button>`).join("");
}
[...itemsBox.children].forEach((button, index) => {
const key = keys[index];
if (key === "window") { button.innerHTML = ICON.dash; return; }
const awake = index === 0 && S.keepAwake ? `<span class="mbs" title="防休眠已开启">${ICON.cup}</span>` : "";
button.innerHTML = awake + itemHTML(key, S.style);
button.setAttribute("aria-expanded", String(S.popover === key));
});
}
function renderPopover() {
if (!S.popover) { popover.hidden = true; return; }
const [title, icon] = ITEM_META[S.popover];
const previous = popover.querySelector(".op-popbody");
const scroll = previous && previous.dataset.item === S.popover ? previous.scrollTop : 0;
popover.innerHTML = `<div class="op-pophead">${icon}<b>${title}</b>
<button class="op-icon" type="button" data-action="window" aria-label="打开 OpenStats 主窗口">${ICON.window}</button>
<button class="op-icon" type="button" data-action="settings" aria-label="设置">${ICON.gear}</button></div>
<div class="op-popbody" data-item="${S.popover}">${CONTENT[S.popover](false)}</div>`;
popover.hidden = false;
popover.querySelector(".op-popbody").scrollTop = scroll;
placePopover();
}
function placePopover() {
const anchor = itemsBox.querySelector(`[data-item="${S.popover}"]`);
if (!anchor) return;
const margin = 8;
const width = Math.min(320, window.innerWidth - margin * 2);
const rect = anchor.getBoundingClientRect();
const left = clamp(rect.left + rect.width / 2 - width / 2, margin, window.innerWidth - width - margin);
popover.style.left = `${left}px`;
popover.style.width = `${width}px`;
}
function openPopover(key) {
S.popover = S.popover === key ? null : key;
renderMenubar();
renderPopover();
}
function closePopover() {
if (!S.popover) return;
S.popover = null;
renderMenubar();
renderPopover();
}
// ── 主窗口 ────────────────────────────────────────────────
const appWindow = document.getElementById("osWindow");
const PAGES = [
["监控", [["overview", "仪表盘", ICON.dash], ["cpu", "CPU", ICON.cpu], ["gpu", "GPU", ICON.gpu], ["mem", "内存", ICON.mem], ["net", "网络", ICON.net], ["temp", "温度与风扇", ICON.fan]]],
["工具", [["processes", "进程", ICON.list], ["keepAwake", "防休眠", ICON.cup], ["cleaner", "清理", ICON.clean]]],
];
const pageTitle = (id) => PAGES.flatMap(([, list]) => list).find(([p]) => p === id)[1];
function overviewPage() {
const maxFan = Math.max(...S.fans);
const fanPct = Math.round((S.fans[0] / FAN_RANGE[0][1] + S.fans[1] / FAN_RANGE[1][1]) / 2 * 100);
const cores = S.cores.slice(12).concat(S.cores.slice(0, 12));
const coreBars = cores.map((v, i) => `${i === 6 ? '<i class="gap"></i>' : ""}<i><b style="height:${Math.round(v * 100)}%"></b></i>`).join("");
const fanModes = [["auto", "自动"], ["cool", "降温"], ["max", "强冷"]].map(([m, t]) =>
`<button class="op-chipbtn" type="button" data-fan="${m}" aria-pressed="${S.fanMode === m}">${t}</button>`).join("");
return `<div class="op-health">
<svg width="24" height="24" viewBox="0 0 24 24"><path class="fill-success" d="M12 1.5 14.6 3.4l3.2-.2 1 3 2.7 1.8-1 3.1 1 3.1-2.7 1.8-1 3-3.2-.2L12 22.5l-2.6-1.9-3.2.2-1-3-2.7-1.8 1-3.1-1-3.1 2.7-1.8 1-3 3.2.2z"/><path class="stroke-white" d="m8 12.2 2.6 2.6L16.2 9"/></svg>
<span class="op-health__score">100</span><span class="op-health__text">各项指标正常</span>
</div>
<div class="op-badges">
<span class="op-chip">${ICON.apple} M5 Max</span><span class="op-chip">64 GB</span><span class="op-chip">macOS 27.0</span>
<span class="op-chip">已运行 3 天 4 小时</span><span class="op-chip">MacBook Pro 16 英寸</span>
</div>
<div class="op-row op-row--3">
${card(`CPU <span class="op-chip ${tempTone(S.cpuTemp)}">${Math.round(S.cpuTemp)}°C</span>`,
`<div class="op-value">${Math.round(S.cpu * 100)}<small>%</small></div>${barsSVG(S.cpuHistory)}<div class="op-foot">${level(S.cpu)} · 负载 ${(S.cpu * 18).toFixed(1)}/18</div>`)}
${card(`GPU <span class="op-chip ${tempTone(S.gpuTemp)}">${Math.round(S.gpuTemp)}°C</span>`,
`<div class="op-value">${Math.round(S.gpu * 100)}<small>%</small></div>${lineSVG(S.gpuHistory, { cls: "secondary" })}<div class="op-foot">${level(S.gpu)} · 40 核</div>`)}
${card(`内存 <span class="op-chip op-chip--success">压力 正常</span>`,
`<div class="op-value">${Math.round(S.mem * 100)}<small>%</small></div>${lineSVG(S.memHistory)}<div class="op-foot">${(S.mem * 64).toFixed(1)} GB / 64.0 GB</div>`)}
</div>
<div class="op-row op-row--3">
${card(`磁盘 <span class="op-chip">2 TB</span>`,
`<div class="op-value">16<small>%</small></div><div class="op-chart op-chart--center"><div class="op-track"><i style="width:16%"></i></div></div><div class="op-foot">可用 1.7 TB</div>`)}
${card(`网络 <span class="op-chip">Wi‑Fi</span>`,
`<div class="op-value">${splitRate(S.up + S.down)}</div>${dualSVG(S.upHistory, S.downHistory)}<div class="op-foot"><span class="up-text">↑</span> ${rate(S.up)} <span class="down-text">↓</span> ${rate(S.down)}</div>`)}
${card(`风扇 <span class="op-chip">转速 ${fanPct}%</span>`,
`<div class="op-value">${maxFan.toLocaleString("en-US")}<small>RPM</small></div><div class="op-foot">${S.fanMode === "auto" ? "由 macOS 调节" : "OpenStats 控制中"}</div><div class="op-chipgrid op-chipgrid--3">${fanModes}</div>`)}
</div>
<div class="op-row op-row--2-1">
${card(`核心负载 <span class="op-legend"><i class="bg-primary"></i>用户 <b>${Math.round(S.cpu * 72)}%</b></span><span class="op-legend"><i class="bg-secondary"></i>系统 <b>${Math.round(S.cpu * 28)}%</b></span>`,
`<div class="op-cores">${coreBars}</div><div class="op-core-labels"><span>超级核 · 6</span><span>性能核 · 12</span></div>`)}
${card(`电池 <span class="op-chip">健康 100%</span>`,
`<div class="op-battery"><div><div class="op-value">100%</div><div class="op-foot">已充满 · 电源适配器</div><div class="op-foot">循环 28 次</div></div>${ringSVG(1, 56, 6, "ring-success")}</div>`)}
</div>
<div class="op-row op-row--2-1">
${card(`高占用进程 <span class="op-card__note">CPU · 内存</span>`, procRows([...PROCESSES].sort((a, b) => b.cpu - a.cpu).slice(0, 5), [cpuText, memText]))}
${card("快捷开关", `
<button class="op-quick" type="button" data-action="awake" aria-pressed="${S.keepAwake}">${ICON.cup}<span>防休眠</span><span>${S.keepAwake ? "已开启" : "关闭"}</span></button>
<button class="op-quick" type="button" data-action="lid" aria-pressed="${S.lidClosed}">${ICON.laptop}<span>合盖运行</span><span>${S.lidClosed ? "已开启" : "关闭"}</span></button>
<button class="op-quick" type="button" data-page="temp" aria-pressed="${S.fanMode !== "auto"}">${ICON.fan}<span>散热模式</span><span>${{ auto: "自动", cool: "降温", max: "强冷", custom: "自定义" }[S.fanMode]}</span></button>`)}
</div>`;
}
function processesPage() {
const list = [...PROCESSES].sort((a, b) => (S.sort === "cpu" ? b.cpu - a.cpu : b.mem - a.mem));
return `<div class="op-seg op-seg--narrow">
<button type="button" data-sort="cpu" aria-pressed="${S.sort === "cpu"}">按 CPU</button>
<button type="button" data-sort="mem" aria-pressed="${S.sort === "mem"}">按内存</button>
</div>
${card(`进程 <span class="op-card__note">占用最高的 ${list.length} 个</span>`, procRows(list, [cpuText, memText]))}
<div class="op-foot">仅列出当前用户有权限读取的进程,CPU 以单核满载为 100%。</div>`;
}
function thermalPage() {
const temps = [["CPU", S.cpuTemp], ["GPU", S.gpuTemp], ["内存", S.memTemp], ["电池", S.batteryTemp], ["掌托", S.palmTemp]]
.map(([t, v]) => `<div class="op-list-row"><span class="op-list-row__title">${t}</span><div class="op-track"><i class="${v >= 80 ? "bg-warning" : ""}" style="width:${Math.round(v / 110 * 100)}%"></i></div><span class="op-list-row__value">${Math.round(v)}°C</span></div>`).join("");
const fans = S.fans.map((rpm, i) => {
const [lo, hi] = FAN_RANGE[i];
return `<div class="op-list-row"><span class="op-list-row__title">${i ? "右侧" : "左侧"}</span><div class="op-track"><i class="bg-secondary" style="width:${Math.round((rpm - lo) / (hi - lo) * 100)}%"></i></div><span class="op-list-row__value">${rpm}</span></div>`;
}).join("");
const modes = [["auto", "自动"], ["cool", "降温"], ["max", "强冷"], ["custom", "自定义"]]
.map(([m, t]) => `<button type="button" data-fan="${m}" aria-pressed="${S.fanMode === m}">${t}</button>`).join("");
return `<div class="op-row op-row--2">
${card(`温度 <span class="op-card__note">41 个传感器</span>`, temps)}
${card(`风扇 <span class="op-chip ${S.fanMode === "auto" ? "" : "op-chip--primary"}">${S.fanMode === "auto" ? "系统自动" : "OpenStats 控制"}</span>`,
`${fans}<div class="op-seg">${modes}</div><div class="op-foot">这是网页演示,不会改动你电脑的风扇。</div>`)}
</div>`;
}
function keepAwakePage() {
const modes = [["display", "屏幕保持常亮", "屏幕和系统都不会因闲置而关闭或休眠"], ["system", "仅系统不休眠", "屏幕可按设置关闭,后台任务继续运行"]]
.map(([m, t, d]) => `<button class="op-radio" type="button" role="radio" data-awake-mode="${m}" aria-checked="${S.awakeMode === m}"><i></i><span><b>${t}</b><small>${d}</small></span></button>`).join("");
const durations = [[15, "15 分钟"], [60, "1 小时"], [120, "2 小时"], [300, "5 小时"], [0, "不限"]]
.map(([m, t]) => `<button type="button" data-duration="${m}" aria-pressed="${S.duration === m}">${t}</button>`).join("");
return `<div class="op-card"><div class="op-list-row">
<span class="op-value">防休眠</span>
<span class="op-foot">${S.keepAwake ? "已开启 · " + (S.duration ? `${S.duration} 分钟后恢复` : "不限时") : "未开启,Mac 按系统设置休眠"}</span>
<button class="op-toggle" type="button" role="switch" data-action="awake" aria-checked="${S.keepAwake}" aria-label="防休眠"></button>
</div></div>
<div class="op-row op-row--2">
<div class="op-card"><div class="op-card__head">模式</div>${modes}<div class="op-card__head">持续时间</div><div class="op-seg">${durations}</div></div>
<div class="op-card">
<div class="op-list-row"><span><b>合盖后继续运行</b><br><span class="op-foot">合上屏幕时 Mac 不进入睡眠</span></span>
<button class="op-toggle" type="button" role="switch" data-action="lid" aria-checked="${S.lidClosed}" aria-label="合盖后继续运行"></button></div>
<div class="op-banner">${ICON.warn}<span>合盖运行时散热变差,请勿放入包中。使用电池且电量低于 20% 时会自动关闭。</span></div>
</div>
</div>`;
}
function cleanerPage() {
const selected = CLEAN_RULES.filter((r) => r.on && !r.blocked && !S.cleaned);
const total = selected.reduce((a, r) => a + r.size, 0);
const cats = [...new Set(CLEAN_RULES.map((r) => r.cat))];
const groups = cats.map((cat) => card(cat, CLEAN_RULES.filter((r) => r.cat === cat).map((r) => `
<div class="op-clean-row">
<button class="op-check" type="button" role="checkbox" data-rule="${r.id}" aria-checked="${r.on && !r.blocked}" ${r.blocked || S.cleaned ? "disabled" : ""} aria-label="${r.title}"></button>
<span><b>${r.title}</b><small class="${r.blocked ? "warning-text" : ""}">${S.cleaned && r.on ? "无需清理" : r.detail}</small></span>
<span class="size">${r.blocked || (S.cleaned && r.on) ? "" : bytes(r.size)}</span>
</div>`).join(""))).join("");
let action;
if (S.cleaned) {
action = `<div class="op-banner op-banner--success">${ICON.check}<span>已释放 ${bytes(CLEAN_RULES.filter((r) => r.on && !r.blocked).reduce((a, r) => a + r.size, 0))}(网页演示,没有删除任何文件)</span></div>
<div class="op-actions"><button class="op-btn" type="button" data-action="rescan">重新扫描</button></div>`;
} else if (S.confirming) {
action = `<div class="op-banner">${ICON.warn}<span>将清理 ${selected.length} 类项目,共 ${bytes(total)}。缓存与日志直接删除,下载内容移到废纸篓。</span></div>
<div class="op-actions"><button class="op-btn" type="button" data-action="cancel">取消</button><button class="op-btn op-btn--primary" type="button" data-action="confirm">确认清理</button></div>`;
} else {
action = `<div class="op-actions"><button class="op-btn" type="button" data-action="rescan">重新扫描</button>
<button class="op-btn op-btn--primary" type="button" data-action="clean" ${total ? "" : "disabled"}>${S.cleaning ? "清理中…" : `清理所选 ${bytes(total)}`}</button></div>`;
}
return `${card(`磁盘清理 <span class="op-card__note">扫描于 刚刚</span>`, `
<div class="op-value">${bytes(S.cleaned ? 0 : CLEAN_RULES.filter((r) => !r.blocked).reduce((a, r) => a + r.size, 0))}<small>可清理</small></div>
<div class="op-track"><i style="width:16%"></i></div><div class="op-foot">Macintosh HD 可用 1.7 TB · 共 2.0 TB</div>${action}`)}
<div class="op-row op-row--2">${groups}</div>`;
}
const WINDOW_PAGES = {
overview: overviewPage, processes: processesPage, keepAwake: keepAwakePage, cleaner: cleanerPage,
cpu: () => CONTENT.cpu(true), gpu: () => CONTENT.gpu(true), mem: () => CONTENT.mem(true), net: () => CONTENT.net(true), temp: () => CONTENT.temp(true),
};
function renderWindow() {
if (!S.windowOpen) { appWindow.hidden = true; return; }
const scroller = appWindow.querySelector(".ow-content");
const scroll = scroller ? scroller.scrollTop : 0;
const nav = PAGES.map(([group, list]) => `<div class="ow-group">${group}</div>` + list.map(([id, title, icon]) =>
`<button class="ow-nav" type="button" data-page="${id}" aria-current="${S.page === id ? "page" : "false"}">${icon}<span>${title}</span></button>`).join("")).join("");
const toggle = ITEM_META[S.page]
? `<span class="ow-toggle-label">在菜单栏显示</span><button class="op-toggle" type="button" role="switch" data-action="menubar-item" aria-checked="${S.items.includes(S.page)}" aria-label="在菜单栏显示${pageTitle(S.page)}"></button>`
: "";
appWindow.innerHTML = `
<aside class="ow-sidebar">
<div class="ow-lights ow-drag"><button type="button" data-action="window-close" aria-label="关闭窗口"></button><i></i><i></i></div>
<nav class="ow-navlist" aria-label="OpenStats 页面">${nav}</nav>
<div class="ow-tools">
<button class="op-icon" type="button" data-action="theme" aria-label="切换浅色 / 深色">${ICON.moon}</button>
<button class="op-icon" type="button" data-action="settings" aria-label="设置">${ICON.gear}</button>
<button class="op-icon" type="button" data-action="window-close" aria-label="关闭窗口">${ICON.power}</button>
</div>
</aside>
<section class="ow-main">
<header class="ow-header ow-drag"><h2>${pageTitle(S.page)}</h2>${toggle}</header>
<div class="ow-content">${WINDOW_PAGES[S.page]()}</div>
</section>`;
appWindow.hidden = false;
appWindow.querySelector(".ow-content").scrollTop = scroll;
}
function openWindow(page) {
if (page) S.page = page;
S.windowOpen = true;
closePopover();
renderWindow();
document.getElementById("dockOpenStats")?.classList.add("dock__item--running");
}
// ── 交互 ──────────────────────────────────────────────────
function handle(target, event) {
const d = target.dataset;
if (d.copy) {
navigator.clipboard?.writeText(d.copy).catch(() => {});
target.textContent = "已拷贝";
target.classList.add("is-copied");
setTimeout(() => { target.textContent = d.copy; target.classList.remove("is-copied"); }, 1200);
return false;
}
if (d.item) { event.stopPropagation(); openPopover(d.item); return false; }
if (d.page) S.page = d.page;
if (d.fan) S.fanMode = d.fan;
if (d.sort) S.sort = d.sort;
if (d.awakeMode) S.awakeMode = d.awakeMode;
if (d.duration !== undefined) S.duration = Number(d.duration);
if (d.dns) { S.dns = d.dns; S.dnsManual = false; S.dnsNote = "网页演示:没有修改你电脑的 DNS。"; }
if (d.rule) { const r = CLEAN_RULES.find((x) => x.id === d.rule); r.on = !r.on; S.confirming = false; }
switch (d.action) {
case "theme": window.OpenStatsSite?.toggleTheme(); break;
case "settings": closePopover(); document.getElementById("styles").scrollIntoView(); return false;
case "window": openWindow(S.popover === "temp" ? "temp" : S.popover || null); return false;
case "window-close": S.windowOpen = false; renderWindow(); return false;
case "menubar-item": {
S.items = S.items.includes(S.page) ? S.items.filter((k) => k !== S.page) : [...S.items, S.page];
write("os-items", S.items.join(","));
break;
}
case "dns-manual": S.dnsManual = !S.dnsManual; break;
case "flush": S.dnsNote = "DNS 缓存已刷新(网页演示)"; break;
case "lookup": S.dnsNote = ""; break;
case "purge": target.textContent = "已释放 1.2 GB(网页演示)"; target.disabled = true; return false;
case "awake": S.keepAwake = !S.keepAwake; if (!S.keepAwake) S.lidClosed = false; break;
case "lid": S.lidClosed = !S.lidClosed; if (S.lidClosed) S.keepAwake = true; break;
case "clean": S.confirming = true; break;
case "cancel": S.confirming = false; break;
case "confirm": S.confirming = false; S.cleaning = true; setTimeout(() => { S.cleaning = false; S.cleaned = true; renderWindow(); }, 900); break;
case "rescan": S.cleaned = false; S.confirming = false; break;
}
return true;
}
document.addEventListener("click", (event) => {
const target = event.target.closest("[data-item], [data-page], [data-fan], [data-sort], [data-awake-mode], [data-duration], [data-dns], [data-rule], [data-action], [data-copy]");
const inPopover = popover.contains(event.target);
const inWindow = appWindow.contains(event.target);
const inBar = itemsBox?.contains(event.target);
if (target && (inPopover || inWindow || inBar)) {
if (inPopover || inWindow) event.stopPropagation();
if (handle(target, event)) { renderMenubar(); renderPopover(); renderWindow(); }
return;
}
if (S.popover && !inPopover) closePopover();
});
document.addEventListener("submit", (event) => {
const form = event.target.closest('[data-form="dns"]');
if (!form) return;
event.preventDefault();
const input = form.elements.servers;
const parts = input.value.split(/[\s,,]+/).filter(Boolean);
const ipv4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
const valid = parts.length > 0 && parts.length <= 6 && parts.every((p) => ipv4.test(p) || /^[0-9a-f:]+$/i.test(p) && p.includes(":"));
S.dnsNote = valid ? `网页演示:会把 DNS 设为 ${parts.join("、")},没有修改你的电脑。` : "地址格式不正确,只接受 IPv4 / IPv6 地址。";
if (valid) S.dnsManual = false;
renderPopover();
renderWindow();
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closePopover(); });
window.addEventListener("resize", () => { if (S.popover) placePopover(); });
document.getElementById("dockOpenStats")?.addEventListener("click", (e) => { e.stopPropagation(); openWindow(); });
document.getElementById("heroOpenWindow")?.addEventListener("click", (e) => { e.stopPropagation(); openWindow("net"); });
// 按住窗口顶栏或侧边栏顶部拖动(仅桌面宽度)
appWindow.addEventListener("pointerdown", (event) => {
const handleBar = event.target.closest(".ow-drag");
if (!handleBar || event.target.closest("button") || window.innerWidth < 900) return;
const start = { x: event.clientX, y: event.clientY, left: appWindow.offsetLeft, top: appWindow.offsetTop };
const move = (e) => {
appWindow.style.left = `${start.left + e.clientX - start.x}px`;
appWindow.style.top = `${Math.max(28, start.top + e.clientY - start.y)}px`;
appWindow.style.transform = "none";
};
const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
// ── 刘海岛、风格选择 ───────────────────────────────────────
const bind = (key, fn) => document.querySelectorAll(`[data-bind="${key}"]`).forEach(fn);
function renderIsland() {
bind("island-cpu", (el) => { el.textContent = `CPU ${pct(S.cpu)} · ${Math.round(S.cpuTemp)}°`; });
const tiles = { cpu: [S.cpu, pct(S.cpu)], gpu: [S.gpu, pct(S.gpu)], mem: [S.mem, pct(S.mem)], temp: [S.cpuTemp / 110, `${Math.round(S.cpuTemp)}°`] };
for (const [k, [f, text]] of Object.entries(tiles)) {
bind(`island-${k}-value`, (el) => { el.textContent = text; });
bind(`island-${k}-bar`, (el) => { el.style.width = `${Math.round(clamp(f, 0, 1) * 100)}%`; });
}
bind("island-up", (el) => { el.textContent = rate(S.up); });
bind("island-down", (el) => { el.textContent = rate(S.down); });
bind("island-fan", (el) => { el.textContent = `风扇 ${Math.max(...S.fans).toLocaleString("en-US")} RPM`; });
}
const picker = document.getElementById("stylePicker");
if (picker) {
picker.innerHTML = STYLES.map(([id, title]) =>
`<button class="style-chip" type="button" role="radio" data-style="${id}" aria-checked="${S.style === id}">${title}</button>`).join("");
}
function renderStyles() {
if (!picker) return;
picker.querySelectorAll("[data-style]").forEach((chip) => chip.setAttribute("aria-checked", String(chip.dataset.style === S.style)));
const preview = ["cpu", "mem", "gpu"].map((k) => `<span class="mb-stats mb-stats--static">${itemHTML(k, S.style)}</span>`).join("");
document.getElementById("stylePreviewLight").innerHTML = preview;
document.getElementById("stylePreviewDark").innerHTML = preview;
document.getElementById("styleCaption").textContent = STYLES.find(([id]) => id === S.style)[2];
}
picker?.addEventListener("click", (e) => {
const chip = e.target.closest("[data-style]");
if (!chip) return;
S.style = chip.dataset.style;
write("os-style", S.style);
renderStyles();
renderMenubar();
});
const island = document.getElementById("island");
const setIsland = (open) => {
island.classList.toggle("is-open", open);
island.setAttribute("aria-expanded", String(open));
island.querySelector(".island__expanded").setAttribute("aria-hidden", String(!open));
};
island.addEventListener("mouseenter", () => setIsland(true));
island.addEventListener("mouseleave", () => setIsland(false));
island.addEventListener("click", (e) => { e.stopPropagation(); setIsland(!island.classList.contains("is-open")); });
// ── 循环 ──────────────────────────────────────────────────
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const editing = (el) => el.contains(document.activeElement) && document.activeElement.matches("input");
// 按下后 1 秒内不重绘:触屏没有悬停状态,按下与抬起之间换掉按钮会让点击落空
let holdUntil = 0;
for (const el of [popover, appWindow]) {
el.addEventListener("pointerdown", () => { holdUntil = Date.now() + 1000; }, true);
}
const busy = (el) => Date.now() < holdUntil || el.matches(":hover") || editing(el);
function frame() {
tick();
renderMenubar();
renderIsland();
renderStyles();
if (S.popover && !busy(popover)) renderPopover();
if (S.windowOpen && !busy(appWindow)) renderWindow();
}
frame();
if (!reduced) setInterval(frame, 1500);
})();
+272
View File
@@ -0,0 +1,272 @@
/* OpenStats 官网 · 页面基础与介绍区
所有颜色、字号、间距、圆角与阴影都从下面的 token 取值。 */
:root {
color-scheme: light dark;
/* 品牌与语义色 */
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-secondary: #0d9488;
--color-success: #16a34a;
--color-warning: #d97706;
--color-error: #dc2626;
/* 中性色 */
--color-neutral-50: #f8f9fa;
--color-neutral-100: #f3f4f6;
--color-neutral-200: #e5e7eb;
--color-neutral-300: #d1d5db;
--color-neutral-400: #9ca3af;
--color-neutral-500: #6b7280;
--color-neutral-700: #374151;
--color-neutral-800: #1f2937;
--color-neutral-900: #111827;
--color-bg: #f8f9fa;
--color-bg-alt: #ffffff;
--color-surface: #ffffff;
--color-border: #e5e7eb;
--color-text: #111827;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-on-primary: #ffffff;
/* 字号 */
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
/* 间距(4px 栅格) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;
/* 圆角:控件 < 卡片 < 窗口 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* 阴影(全站只用两级) */
--shadow-1: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-2: 0 4px 12px rgba(0, 0, 0, 0.1);
--shell: 1120px;
--narrow: 760px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
}
:root[data-theme="dark"] {
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-secondary: #2dd4bf;
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #f87171;
--color-bg: #111315;
--color-bg-alt: #16181c;
--color-surface: #1d1f23;
--color-border: #2c2f35;
--color-text: #f3f4f6;
--color-text-secondary: #9ca3af;
--color-text-tertiary: #6b7280;
--shadow-1: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-2: 0 4px 12px rgba(0, 0, 0, 0.4);
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font-family: var(--font);
font-size: var(--text-base);
font-weight: 400;
line-height: 1.5;
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
img { max-width: 100%; display: block; }
a { color: var(--color-primary); text-decoration: none; }
a:hover { color: var(--color-primary-hover); }
h1, h2, h3 { font-weight: 600; line-height: 1.25; margin: 0; }
p { margin: 0; }
:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}
/* ── 按钮 ─────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 40px;
padding: 0 var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
border: 1px solid transparent;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.btn--primary { background: var(--color-primary); color: var(--color-on-primary); }
.btn--primary:hover { background: var(--color-primary-hover); color: var(--color-on-primary); }
.btn--secondary { background: var(--color-surface); color: var(--color-text); border-color: var(--color-border); }
.btn--secondary:hover { background: var(--color-neutral-100); color: var(--color-text); }
:root[data-theme="dark"] .btn--secondary:hover { background: #25282d; }
/* ── 介绍区 ───────────────────────────────────────────── */
.shell { max-width: var(--shell); margin: 0 auto; padding: 0 var(--space-6); }
.narrow { max-width: var(--narrow); }
.band { padding: var(--space-16) 0; }
.band--alt { background: var(--color-bg-alt); border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); }
.section-head { margin-bottom: var(--space-8); }
.section-head h2 { font-size: var(--text-2xl); }
.section-head p { margin-top: var(--space-2); color: var(--color-text-secondary); font-size: var(--text-base); }
.feature-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
.feature {
padding: var(--space-6);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.feature h3 { font-size: var(--text-base); }
.feature p { margin-top: var(--space-2); font-size: var(--text-sm); color: var(--color-text-secondary); }
/* 风格选择 */
.style-picker { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-bottom: var(--space-6); }
.style-chip {
height: 36px;
padding: 0 var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
}
.style-chip:hover { border-color: var(--color-neutral-400); }
.style-chip[aria-checked="true"] { border-color: var(--color-primary); color: var(--color-primary); font-weight: 600; }
.style-preview { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
.style-preview__bar {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg);
}
.style-preview__bar--light { background: var(--color-neutral-200); color: #000; }
.style-preview__bar--dark { background: var(--color-neutral-800); color: #fff; }
.style-preview__caption { grid-column: 1 / -1; font-size: var(--text-sm); color: var(--color-text-secondary); }
/* 截图 */
.shots { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-6); align-items: start; }
.shot { margin: 0; }
.shot img { border-radius: var(--radius-lg); box-shadow: var(--shadow-2); }
.shot--popover { max-width: 320px; justify-self: center; }
.shot--popover img { border-radius: var(--radius-xl); }
.shot--dark { display: none; }
:root[data-theme="dark"] .shot--light { display: none; }
:root[data-theme="dark"] .shot--dark { display: block; }
.shot figcaption { margin-top: var(--space-3); text-align: center; font-size: var(--text-sm); color: var(--color-text-secondary); }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-12); align-items: center; }
.split--reverse .split__text { order: 2; }
.split__text h2 { font-size: var(--text-2xl); margin-bottom: var(--space-6); }
.split__text p { margin-top: var(--space-4); color: var(--color-text-secondary); }
.split__text .note { font-size: var(--text-sm); color: var(--color-text-tertiary); }
.mode-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
.mode-table th, .mode-table td { text-align: left; padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--color-border); }
.mode-table th { color: var(--color-text-secondary); font-weight: 600; }
.check-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
.check-list li { position: relative; padding-left: var(--space-6); }
.check-list li::before { content: "✓"; position: absolute; left: 0; color: var(--color-success); font-weight: 600; }
.fact-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); }
.fact-list li { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-4); padding: var(--space-3) var(--space-4); border-top: 1px solid var(--color-border); font-size: var(--text-sm); }
.fact-list li:first-child { border-top: 0; }
.fact-list b { font-weight: 600; }
.fact-list span { color: var(--color-text-secondary); }
.download { text-align: center; display: flex; flex-direction: column; align-items: center; gap: var(--space-4); }
.download h2 { font-size: var(--text-2xl); }
.download p { color: var(--color-text-secondary); }
/* 更新日志(由 Scripts/sync_changelog.py 生成) */
.log { display: grid; gap: var(--space-4); }
.log__day { padding: var(--space-6); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); }
.log__head { display: flex; align-items: center; gap: var(--space-3); }
.log__head time { font-weight: 600; }
.log__tag { font-size: var(--text-xs); padding: 0 var(--space-2); border-radius: var(--radius-sm); background: var(--color-neutral-100); color: var(--color-text-secondary); }
:root[data-theme="dark"] .log__tag { background: #25282d; }
.log__counts { margin-top: var(--space-1); font-size: var(--text-xs); color: var(--color-text-tertiary); }
.log__list { margin: var(--space-3) 0 0; padding-left: var(--space-4); font-size: var(--text-sm); color: var(--color-text-secondary); display: grid; gap: var(--space-2); }
.log__kind { display: inline-block; margin-right: var(--space-2); font-size: var(--text-xs); font-weight: 600; color: var(--color-primary); }
.log__list code { font-family: var(--font-mono); font-size: var(--text-xs); }
.log__more { display: inline-block; margin-top: var(--space-3); font-size: var(--text-sm); }
.log__all { margin-top: var(--space-6); font-size: var(--text-sm); }
.faq { border-bottom: 1px solid var(--color-border); padding: var(--space-4) 0; }
.faq summary { cursor: pointer; font-weight: 600; list-style: none; }
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after { content: "+"; float: right; color: var(--color-text-tertiary); }
.faq[open] summary::after { content: "−"; }
.faq p { margin-top: var(--space-3); color: var(--color-text-secondary); font-size: var(--text-sm); }
.footer { padding: var(--space-8) 0; border-top: 1px solid var(--color-border); }
.footer__inner { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); }
.footer__brand { display: inline-flex; align-items: center; gap: var(--space-2); font-weight: 600; }
.footer__fine { font-size: var(--text-xs); color: var(--color-text-tertiary); }
@media (max-width: 900px) {
.feature-grid { grid-template-columns: 1fr 1fr; }
.split, .shots { grid-template-columns: 1fr; }
.split--reverse .split__text { order: 0; }
}
@media (max-width: 600px) {
.band { padding: var(--space-12) 0; }
.feature-grid, .style-preview { grid-template-columns: 1fr; }
.section-head h2, .split__text h2, .download h2 { font-size: var(--text-xl); }
.fact-list li { grid-template-columns: 1fr; gap: var(--space-1); }
}