Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift
T
gentpan 8875b6e709 每项独立菜单栏与详情弹窗、网络详情与 DNS、侧边栏主窗口与统一外观
- 菜单栏可每项独立(点击弹出该项窄详情)或合并为一个(点击打开主窗口),详情区块可在设置中勾选
- 网络详情:镜像流量历史、ICMP 连接探测格子、接口与 Wi-Fi、本地与公网 IPv4 / IPv6、SVG 国旗归属地与 ASN、各进程流量
- DNS:刷新缓存,一键切换自动 / Cloudflare / Google / 腾讯 / 阿里云或手动填写;辅助工具协议升至 3,服务名与地址二次校验
- 主窗口:侧边栏切换仪表盘、各指标详情与工具页,宽高可调,卡片按比例伸缩
- 统一外观:白底蓝 / 黑底蓝,窗口、设置与弹窗共用底色,去掉玻璃效果
- 新增网络解析、DNS 校验与国旗代码校验的单元测试;更新 README、架构文档与隐私说明
2026-09-13 05:39:10 +05:00

379 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Metrics
import SMC
import SwiftUI
private let heroGauge = DS.Space.s12 + DS.Space.s2
extension View {
/// 详情图表高度:弹窗里紧凑,主窗口里放大
@MainActor
func detailChartHeight(_ isDetailPage: Bool) -> CGFloat {
isDetailPage ? DS.Size.chartHeight * 3 : DS.Size.chartHeight
}
}
private func loadTone(_ value: Double) -> Tone {
value >= 0.85 ? .error : value >= 0.6 ? .warning : .primary
}
// MARK: - CPU
struct CPUPopover: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let settings = model.settings
let store = model.store
let cpu = store.cpu
let temperature = store.sensors?.temperature(.cpu)
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) {
RingGauge(fraction: cpu?.total ?? 0, color: loadTone(cpu?.total ?? 0).color, size: heroGauge) {
Text(verbatim: cpu.map { Format.percent($0.total) } ?? "—")
.dsFont(.sm, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
VStack(alignment: .leading, spacing: DS.Space.s1) {
LegendItem(color: DS.Palette.primary, label: "用户", value: cpu.map { Format.percent($0.user) } ?? "—")
LegendItem(color: DS.Palette.secondary, label: "系统", value: cpu.map { Format.percent($0.system) } ?? "—")
LegendItem(color: DS.Palette.neutral300, label: "空闲",
value: cpu.map { Format.percent(max(0, 1 - $0.total)) } ?? "—")
}
Spacer(minLength: 0)
if let temperature {
Chip(text: Format.temperature(temperature.maximum, fahrenheit: settings.useFahrenheit),
icon: "thermometer.medium", tone: Tone.forTemperature(temperature.maximum))
}
}
}
ForEach(MenuBarItem.cpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .cpuHistory:
SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) {
BarHistoryChart(values: store.cpuTotal.elements, capacity: isDetailPage ? 60 : 40, height: detailChartHeight(isDetailPage))
}
case .cpuCores:
SectionCard(title: section.title) {
CoreClusterBars(topology: store.topology, perCore: cpu?.perCore ?? [])
}
case .cpuDetails:
SectionCard(title: section.title) {
InfoRow(label: "处理器", text: store.topology.brand)
InfoRow(label: "核心", text: coreSummary(store.topology))
InfoRow(label: "负载平均", text: cpu.map { loadAverage($0.loadAverage) } ?? "—")
if let boot = store.system.bootDate {
InfoRow(label: "已运行", text: Format.uptime(since: boot))
}
}
case .cpuProcesses:
SectionCard(title: section.title, trailing: { Text("CPU") }) {
let processes = store.processes
ProcessList(count: processes.count) { index in
let process = processes[index]
HStack(spacing: DS.Space.s2) {
ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName)
Text(verbatim: "\((process.cpu * 100).formatted(.number.precision(.fractionLength(1))))%")
.dsFont(.xs, weight: .medium)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
default:
EmptyView()
}
}
}
private func coreSummary(_ topology: CPUTopology) -> String {
let clusters = topology.clusters.map { "\($0.coreIndices.count) \($0.name)" }.joined(separator: " + ")
return clusters.isEmpty ? "\(topology.logicalCores) 核" : "\(topology.logicalCores) 核(\(clusters))"
}
private func loadAverage(_ values: [Double]) -> String {
values.prefix(3).map { $0.formatted(.number.precision(.fractionLength(2))) }.joined(separator: " · ")
}
}
// MARK: - 内存
struct MemoryPopover: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let settings = model.settings
let store = model.store
let memory = store.memory
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) {
RingGauge(fraction: memory?.usedFraction ?? 0, color: pressureTone(memory?.pressure).color, size: heroGauge) {
Text(verbatim: memory.map { Format.percent($0.usedFraction) } ?? "—")
.dsFont(.sm, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
VStack(alignment: .leading, spacing: DS.Space.s1) {
HeroValue(value: memory.map { Format.bytes($0.used) } ?? "—", unit: memory.map { "/ \(Format.bytes($0.total))" }, size: .lg)
StatusBadge(text: "内存压力 \(memory?.pressure.title ?? "—")", tone: pressureTone(memory?.pressure))
}
Spacer(minLength: 0)
}
}
ForEach(MenuBarItem.memory.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .memoryHistory:
SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) {
LineHistoryChart(values: store.memoryHistory.elements, height: detailChartHeight(isDetailPage))
}
case .memoryBreakdown:
SectionCard(title: section.title) {
if let memory {
MemoryBar(memory: memory)
LegendRow(color: DS.Palette.primary, label: "App 内存", value: Format.bytes(memory.app))
LegendRow(color: DS.Palette.secondary, label: "联动内存", value: Format.bytes(memory.wired))
LegendRow(color: DS.Palette.warning, label: "被压缩", value: Format.bytes(memory.compressed))
LegendRow(color: DS.Palette.neutral300, label: "缓存文件", value: Format.bytes(memory.cached))
LegendRow(color: DS.Palette.track, label: "交换空间", value: Format.bytes(memory.swapUsed))
}
PurgeMemoryRow()
}
case .memoryProcesses:
SectionCard(title: section.title, trailing: { Text("内存") }) {
let processes = store.processes.sorted { $0.memory > $1.memory }
ProcessList(count: processes.count) { index in
let process = processes[index]
HStack(spacing: DS.Space.s2) {
ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName)
Text(verbatim: Format.bytes(process.memory))
.dsFont(.xs, weight: .medium)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
default:
EmptyView()
}
}
}
private func pressureTone(_ pressure: MemoryPressure?) -> Tone {
switch pressure {
case .normal: .success
case .warning: .warning
case .critical: .error
case nil: .neutral
}
}
}
private struct LegendRow: View {
let color: Color
let label: String
let value: String
var body: some View {
HStack(spacing: DS.Space.s2) {
RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(color)
.frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer(minLength: DS.Space.s2)
Text(verbatim: value).dsFont(.xs, weight: .medium).monospacedDigit().foregroundStyle(DS.Palette.textPrimary)
}
}
}
/// App / 联动 / 压缩三段堆叠条,剩余部分为可用
private struct MemoryBar: View {
let memory: MemoryUsage
var body: some View {
GeometryReader { proxy in
let total = max(1, Double(memory.total))
let parts: [(UInt64, Color)] = [(memory.app, DS.Palette.primary), (memory.wired, DS.Palette.secondary),
(memory.compressed, DS.Palette.warning)]
HStack(spacing: DS.Size.stroke) {
ForEach(Array(parts.enumerated()), id: \.offset) { _, part in
Rectangle().fill(part.1)
.frame(width: max(0, proxy.size.width * Double(part.0) / total - DS.Size.stroke))
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
.background(DS.Palette.track)
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm))
}
.frame(height: DS.Space.s3)
}
}
private struct PurgeMemoryRow: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let maintenance = model.maintenance
HStack(spacing: DS.Space.s2) {
Button(maintenance.running == .purgeMemory ? "正在释放…" : "释放内存") {
Task { await maintenance.run(.purgeMemory) }
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(maintenance.running != nil)
if let outcome = maintenance.outcomes[.purgeMemory] {
Text(outcome.text)
.dsFont(.xs)
.foregroundStyle(outcome.isError ? DS.Palette.error : DS.Palette.textSecondary)
.lineLimit(2)
}
Spacer(minLength: 0)
}
}
}
// MARK: - GPU
struct GPUPopover: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let settings = model.settings
let store = model.store
let gpu = store.gpu
let temperature = store.sensors?.temperature(.gpu)
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) {
RingGauge(fraction: gpu?.utilization ?? 0, color: loadTone(gpu?.utilization ?? 0).color, size: heroGauge) {
Text(verbatim: gpu.map { Format.percent($0.utilization) } ?? "—")
.dsFont(.sm, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(verbatim: gpu?.name ?? "GPU")
.dsFont(.sm, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Text(verbatim: gpu?.coreCount.map { "\($0) 核图形处理器" } ?? "图形处理器")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
Spacer(minLength: 0)
if let temperature {
Chip(text: Format.temperature(temperature.maximum, fahrenheit: settings.useFahrenheit),
icon: "thermometer.medium", tone: Tone.forTemperature(temperature.maximum))
}
}
}
ForEach(MenuBarItem.gpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .gpuHistory:
SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) {
LineHistoryChart(values: store.gpuHistory.elements, color: DS.Palette.secondary, height: detailChartHeight(isDetailPage))
}
case .gpuDetails:
SectionCard(title: section.title) {
InfoRow(label: "型号", text: gpu?.name ?? "—")
InfoRow(label: "核心数", text: gpu?.coreCount.map(String.init) ?? "—")
InfoRow(label: "平均温度", text: temperature.map { Format.temperature($0.average, fahrenheit: settings.useFahrenheit) } ?? "—")
InfoRow(label: "最高温度", text: temperature.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—")
}
default:
EmptyView()
}
}
}
}
// MARK: - 温度与风扇
struct ThermalPopover: View {
let item: MenuBarItem
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let settings = model.settings
let store = model.store
let cpu = store.sensors?.temperature(.cpu)
let fans = store.sensors?.fans ?? []
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text("CPU 最高").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
HeroValue(value: cpu.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—")
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text("风扇").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
HeroValue(value: store.fastestFan.map { Int($0.current).formatted() } ?? "—", unit: fans.isEmpty ? nil : "RPM")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
ForEach(item.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .thermalSensors:
SectionCard(title: section.title, trailing: { Text("最高 / 平均") }) {
let temperatures = store.sensors?.temperatures ?? []
if temperatures.isEmpty {
Text("正在读取传感器…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(temperatures) { summary in
VStack(spacing: DS.Space.s1) {
InfoRow(label: summary.group.title) {
Text(verbatim: "\(Format.temperature(summary.maximum, fahrenheit: settings.useFahrenheit)) / \(Format.temperature(summary.average, fahrenheit: settings.useFahrenheit))")
}
ProgressTrack(fraction: summary.maximum / DS.Thermal.scaleMax,
color: Tone.forTemperature(summary.maximum).color,
height: DS.Space.s1)
}
}
}
case .thermalFans:
SectionCard(title: section.title, trailing: { Text(fanStatusText(fans: fans, mode: model.fans.mode)) }) {
if fans.isEmpty {
Text("未检测到风扇").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
} else {
ForEach(fans) { fan in
VStack(spacing: DS.Space.s1) {
InfoRow(label: "风扇 \(fan.id + 1)", text: Format.rpm(fan.current))
ProgressTrack(fraction: fan.maximum > 0 ? fan.current / fan.maximum : 0, height: DS.Space.s1)
}
}
HStack(spacing: DS.Space.s1) {
ForEach([FanController.Mode.automatic, .cooling, .maximum]) { mode in
ChipButton(title: mode.title, isSelected: model.fans.mode == mode) {
model.requestFanMode(mode)
}
}
}
}
}
default:
EmptyView()
}
}
}
}
/// 固件只记录“手动 / 自动”,无法区分是谁设置的:OpenStats 未下发时即为其他程序(如 Stats、Mole)
@MainActor
func fanStatusText(fans: [FanState], mode: FanController.Mode) -> String {
if fans.isEmpty { return "未检测到风扇" }
guard fans.contains(where: \.isManual) else { return "由 macOS 调节" }
return mode == .automatic ? "其他程序手动控制" : "OpenStats 控制中"
}