Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift
T
gentpan c9d722f20d 账号同步、磁盘工具、IP 归属地数据源与纯净度、弹窗与设置调整
- 账号与同步:GitHub / Google / Apple 登录后把菜单栏项目与风格等偏好同步到 getopenstats.com,换一台 Mac 登录即恢复(AccountSync 模块、SyncController、SettingsDocument、账号设置页,openstats:// 回调)
- 磁盘页成为工具页:已用 / 可清除 / 可用分段容量条、空间分析、文件系统检查、本地快照清理(辅助工具协议 v4)、其他磁盘推出
- 公网 IP:归属地数据源可选 CleanIP.io / ipapi.is / DB-IP / ipinfo.io / 本地 GeoLite2,每台 Mac 自己查、本地缓存 7 天;IPv4 / IPv6 双栈各自查询与切换;弹窗显示 ASN、运营方、原生 / 住宅等徽章与纯净度评分色带;接口区块可重置上传 / 下载统计
- 弹窗:右上角按钮改为该指标自己的图标 + 总设置;主窗口页面标题栏的开关点不动已修复;滚动条改为悬浮式,内容不再偏左;网络弹窗卡片被标题行撑宽的问题已修复
- 设置:⌘ 拖动排序说明;每个菜单栏项目单独选风格并带实时预览;网络与磁盘的进程榜单固定 5 行并平滑排序;CPU 持续高负载通知
- 设计系统:分段条、评分色带、标签徽章、胶囊切换、刷新按钮等组件与徽章配色 token
- 更新日志、架构说明、README 与译文同步
2026-09-15 03:37:24 +05:00

285 lines
9.6 KiB
Swift

import AppKit
import Localization
import Metrics
import SwiftUI
/// 点击菜单栏单个指标弹出的窄详情。区块可在设置中逐个隐藏
struct PopoverRootView: View {
let item: MenuBarItem
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
var body: some View {
VStack(spacing: 0) {
PopoverHeader(item: item)
.padding(.horizontal, DS.Space.s3)
.padding(.vertical, DS.Space.s2)
PageScroll {
switch item {
case .cpu: CPUPopover()
case .memory: MemoryPopover()
case .network: NetworkPopover()
case .gpu: GPUPopover()
case .disk: DiskPopover()
case .temperature, .fan: ThermalPopover(item: item)
}
}
.frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
}
.frame(width: DS.Size.popoverWidth)
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.fixedSize(horizontal: false, vertical: isSnapshot)
.background(DS.Palette.background, in: RoundedRectangle(cornerRadius: DS.Radius.xl))
.overlay {
RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)
}
}
}
private struct PopoverHeader: View {
let item: MenuBarItem
@Environment(AppModel.self) private var model
var body: some View {
HStack(spacing: DS.Space.s2) {
Image(systemName: item.symbol)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone)
Text(item.popoverTitle)
.dsFont(.base, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Spacer(minLength: DS.Space.s2)
if item == .memory { PurgeMemoryButton() }
// 左边的按钮用该指标自己的图标,点进主窗口里它的页面;右边的齿轮进总设置
let page = PanelTab(item: item)
IconButton(systemName: page.symbol, help: tr("在主窗口打开“\(page.title)”")) { model.openMainWindow(page) }
IconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() }
}
}
}
// MARK: - 共用组件
/// 弹窗里的一个区块:小标题 + 内容。
/// 解释性文字不常驻界面:给 `hint` 后标题旁出现一个很淡的 ⓘ,鼠标悬停在 ⓘ 或卡片内容上才显示说明,看一遍就够的人不用一直看着它
struct SectionCard<Trailing: View, Content: View>: View {
let title: String
var hint: String?
/// 紧跟标题之后的小附件,例如数据来源的 logo
var titleAccessory: AnyView?
@ViewBuilder var trailing: Trailing
@ViewBuilder var content: Content
var body: some View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s2) {
Text(title)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.fixedSize()
if let titleAccessory {
titleAccessory
}
if let hint {
Image(systemName: "info.circle")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .medium))
.foregroundStyle(DS.Palette.textTertiary)
.help(hint)
.accessibilityLabel(hint)
}
Spacer(minLength: DS.Space.s2)
trailing
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
}
content
.help(optional: hint)
}
}
}
extension SectionCard where Trailing == EmptyView {
init(title: String, hint: String? = nil, @ViewBuilder content: () -> Content) {
self.init(title: title, hint: hint, trailing: { EmptyView() }, content: content)
}
}
extension View {
/// 有说明时才挂悬停提示
@ViewBuilder
func help(optional text: String?) -> some View {
if let text { help(text) } else { self }
}
}
/// 左侧标签、右侧数值的一行
struct InfoRow<Value: View>: View {
let label: String
@ViewBuilder var value: Value
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s3) {
Text(label)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.fixedSize()
Spacer(minLength: 0)
value
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.monospacedDigit()
.multilineTextAlignment(.trailing)
}
}
}
extension InfoRow where Value == Text {
init(label: String, text: String) {
self.label = label
self.value = Text(verbatim: text)
}
}
/// 点击拷贝的数值(IP、MAC 地址)
struct CopyableText: View {
let text: String
@State private var copied = false
@State private var hovering = false
var body: some View {
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
copied = true
Task {
try? await Task.sleep(for: .seconds(1.2))
copied = false
}
} label: {
Text(verbatim: copied ? tr("已拷贝") : text)
.foregroundStyle(copied ? DS.Palette.success : hovering ? DS.Palette.primary : DS.Palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.help(tr("点击拷贝"))
.disabled(text == "—")
}
}
/// 区块标题栏里的小图标按钮
struct MiniIconButton: View {
let systemName: String
let help: String
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(hovering ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.frame(width: DS.Size.segmentHeight, height: DS.Size.segmentHeight)
.background(hovering ? DS.Palette.surfaceHover : .clear, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.help(help)
.accessibilityLabel(help)
}
}
/// 区块标题栏里的刷新按钮:查询进行中换成系统的转圈,查完自动变回图标
struct RefreshButton: View {
var loading: Bool
let help: String
let action: () -> Void
var body: some View {
ZStack {
if loading {
ProgressView()
.controlSize(.small)
.accessibilityLabel(tr("正在查询…"))
} else {
MiniIconButton(systemName: "arrow.clockwise", help: help, action: action)
}
}
.frame(width: DS.Size.segmentHeight, height: DS.Size.segmentHeight)
.animation(DS.Motion.quick, value: loading)
}
}
/// 弹窗顶部的大号数值
struct HeroValue: View {
let value: String
var unit: String?
var size: DS.TextSize = .xl
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1 / 2) {
Text(verbatim: value)
.dsFont(size, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
if let unit {
Text(verbatim: unit).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
}
}
.lineLimit(1)
}
}
/// 固定行数的进程列表,数据不足时用占位行,弹窗高度保持不变
struct ProcessList<Row: View>: View {
let count: Int
var rowCount = 5
var emptyText = tr("正在统计…")
@ViewBuilder var row: (Int) -> Row
var body: some View {
if count == 0 {
Text(emptyText)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: Self.rowHeight)
ForEach(1..<rowCount, id: \.self) { _ in
Color.clear.frame(height: Self.rowHeight)
}
} else {
ForEach(0..<min(count, rowCount), id: \.self) { index in
row(index).frame(height: Self.rowHeight)
}
ForEach(0..<max(0, rowCount - count), id: \.self) { _ in
Color.clear.frame(height: Self.rowHeight)
}
}
}
static var rowHeight: CGFloat { DS.Size.iconInline + DS.Space.s1 }
}
struct ProcessNameLabel: View {
let icon: Image
let name: String
var body: some View {
HStack(spacing: DS.Space.s2) {
icon.resizable().frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
Text(verbatim: name)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
}
}
}