Files
gentpan b5c1a330c7 菜单栏合并时点击弹出状态总览,可切到各项详情(#2)
- 合并模式点击图标不再直接打开主窗口,改为弹出面板:顶部标签在总览与菜单栏开启的各项之间切换
- 总览每项一行:数值、状态、走势,偏高时变色;点一行进入该项完整详情,与每项独立时的弹窗共用 PopoverDetail
- 切到某一项时 openPopover 跟着切换,采样范围与单项弹窗一致;面板打开期间每秒刷新
- 合并面板最小高度减半,项目少时不留空白;设置里的弹窗区块选项在合并模式下也可用
- 电池状态文案合成 BatteryStatus.stateText;--show-panel overview 用于调试
2026-09-18 13:59:20 +05:00

236 lines
10 KiB
Swift
Raw Permalink 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 Localization
import Metrics
import SwiftUI
/// 电池详情:电量与充电状态、最近 24 小时电量曲线、功耗、健康度,以及已连接蓝牙设备的电量。
/// 没有电池的 Mac(mini、Studio、iMac)只显示蓝牙设备
struct BatteryPopover: 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 battery = store.battery
BatteryHero(battery: battery)
ForEach(MenuBarItem.battery.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .batteryHistory:
if battery != nil {
BatteryHistorySection()
}
case .batteryPower:
if battery != nil {
SectionCard(title: section.title, trailing: { Text(tr("最近 60 秒")) }) {
PowerRows(power: store.power, history: store.powerHistory.elements, compact: !isDetailPage)
}
}
case .batteryHealth:
if let battery {
BatteryHealthSection(battery: battery)
}
case .batteryBluetooth:
BluetoothSection()
default:
EmptyView()
}
}
}
}
// MARK: - 顶部
private struct BatteryHero: View {
let battery: BatteryStatus?
@Environment(AppModel.self) private var model
var body: some View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
if let battery {
let tone: Tone = battery.level <= 0.2 ? .error : battery.isCharging ? .primary : .success
HStack(spacing: DS.Space.s3) {
RingGauge(fraction: battery.level, color: tone.color, size: DS.Space.s12 + DS.Space.s2) {
Image(systemName: battery.isCharging ? "bolt.fill" : "laptopcomputer")
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.foregroundStyle(battery.isCharging ? DS.Palette.primary : DS.Palette.textSecondary)
}
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
HeroValue(value: Format.percent(battery.level))
Text(battery.stateText)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
if let minutes = battery.minutesRemaining {
Text(battery.isCharging ? tr("\(Format.duration(minutes: minutes))后充满") : tr("还能用 \(Format.duration(minutes: minutes))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
}
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: DS.Space.s1) {
if let watts = battery.adapterWatts, battery.isPluggedIn {
Chip(text: "\(watts) W", icon: "bolt.fill", tone: .warning)
}
if let temperature = model.store.sensors?.temperature(.battery) {
Chip(text: Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit),
icon: "thermometer.medium", tone: Tone.forTemperature(temperature.maximum))
}
}
}
} else {
HStack(spacing: DS.Space.s3) {
Image(systemName: "desktopcomputer")
.font(.system(size: DS.TextSize.lg.rawValue))
.foregroundStyle(DS.Palette.textSecondary)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(tr("这台 Mac 没有电池")).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(tr("这里只显示已连接蓝牙设备的电量")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
Spacer(minLength: 0)
}
}
}
}
}
extension BatteryStatus {
/// 充电中 / 已充满 / 电源供电 / 电池供电
var stateText: String {
if isCharging { return tr("充电中") }
if isPluggedIn { return isFullyCharged ? tr("已充满") : tr("电源供电") }
return tr("电池供电")
}
}
// MARK: - 电量历史
/// 最近 24 小时的电量曲线,来自本机历史库;打开期间每 5 分钟重新读一次
private struct BatteryHistorySection: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let values = model.history.batteryPoints.compactMap(\.battery)
SectionCard(title: PopoverSection.batteryHistory.title, trailing: { Text(tr("最近 24 小时")) }) {
if values.count > 1 {
LineHistoryChart(values: values, capacity: values.count, maxValue: 1, color: DS.Palette.success,
grid: isDetailPage, height: detailChartHeight(isDetailPage))
} else {
Text(tr("还没有足够的记录。菜单栏显示电池或打开这个页面时,每分钟记录一次电量"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
.task {
while !Task.isCancelled {
model.history.loadBattery()
try? await Task.sleep(for: .seconds(300))
}
}
}
}
// MARK: - 健康
private struct BatteryHealthSection: View {
let battery: BatteryStatus
@Environment(AppModel.self) private var model
var body: some View {
SectionCard(title: PopoverSection.batteryHealth.title) {
InfoRow(label: tr("健康度")) {
Text(verbatim: battery.health.map { Format.percent($0) } ?? "—")
.foregroundStyle(battery.health.map { $0 < 0.8 ? DS.Palette.warning : DS.Palette.textPrimary } ?? DS.Palette.textPrimary)
}
InfoRow(label: tr("循环次数"), text: battery.cycleCount.map(String.init) ?? "—")
if let temperature = model.store.sensors?.temperature(.battery) {
InfoRow(label: tr("电池温度"), text: Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit))
}
InfoRow(label: tr("电源"), text: battery.isPluggedIn
? tr("电源适配器\(battery.adapterWatts.map { " · \($0)W" } ?? "")")
: tr("电池供电"))
Text(tr("健康度是现在充满时的容量与出厂容量之比,低于 80% 时苹果建议更换电池"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button(tr("系统电池设置…")) {
if let url = URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(DSButtonStyle(kind: .ghost))
}
}
}
}
// MARK: - 蓝牙设备
/// 已连接蓝牙设备的电量;弹窗打开期间每分钟刷新,也可以手动刷新
private struct BluetoothSection: View {
@Environment(AppModel.self) private var model
var body: some View {
let bluetooth = model.bluetooth
SectionCard(title: PopoverSection.batteryBluetooth.title, trailing: {
if let updatedAt = bluetooth.updatedAt {
Text(verbatim: updatedAt.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: L10n.locale)))
}
RefreshButton(loading: bluetooth.isReading, help: tr("重新读取蓝牙设备电量")) { bluetooth.refresh() }
}) {
BluetoothDeviceList(devices: bluetooth.devices)
}
}
}
/// 蓝牙设备电量列表:本机信息页与电池弹窗共用
struct BluetoothDeviceList: View {
let devices: [BluetoothDevice]?
var body: some View {
if let devices {
if devices.isEmpty {
Text(tr("没有已连接的蓝牙设备")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(devices) { device in
HStack(spacing: DS.Space.s2) {
Image(systemName: device.kind.symbol)
.font(.system(size: DS.TextSize.sm.rawValue))
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone)
Text(verbatim: device.name)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
if device.batteries.isEmpty {
Text(tr("不提供电量")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(device.batteries, id: \.label) { battery in
HStack(spacing: DS.Space.s1) {
if device.batteries.count > 1 {
Text(verbatim: battery.label).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ProgressTrack(fraction: Double(battery.percent) / 100,
color: battery.percent <= 20 ? DS.Palette.error : DS.Palette.success,
height: DS.Space.s1 + DS.Space.s1 / 2)
.frame(width: DS.Space.s8)
Text(verbatim: "\(battery.percent)%")
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.monospacedDigit()
}
}
}
}
} else {
Text(tr("正在读取…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}