- 菜单栏可每项独立(点击弹出该项窄详情)或合并为一个(点击打开主窗口),详情区块可在设置中勾选 - 网络详情:镜像流量历史、ICMP 连接探测格子、接口与 Wi-Fi、本地与公网 IPv4 / IPv6、SVG 国旗归属地与 ASN、各进程流量 - DNS:刷新缓存,一键切换自动 / Cloudflare / Google / 腾讯 / 阿里云或手动填写;辅助工具协议升至 3,服务名与地址二次校验 - 主窗口:侧边栏切换仪表盘、各指标详情与工具页,宽高可调,卡片按比例伸缩 - 统一外观:白底蓝 / 黑底蓝,窗口、设置与弹窗共用底色,去掉玻璃效果 - 新增网络解析、DNS 校验与国旗代码校验的单元测试;更新 README、架构文档与隐私说明
358 lines
15 KiB
Swift
358 lines
15 KiB
Swift
import Cleaner
|
|
import HelperShared
|
|
import Metrics
|
|
import SwiftUI
|
|
|
|
struct CleanerPage: View {
|
|
@Environment(AppModel.self) private var model
|
|
|
|
var body: some View {
|
|
PageScroll {
|
|
WeightedRow(weights: [2, 1]) {
|
|
SummaryCard()
|
|
MaintenanceCard()
|
|
}
|
|
if model.cleaner.needsFullDiskAccess {
|
|
InfoBanner(icon: "lock.shield", text: "部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。", tone: .warning) {
|
|
Button("去授权") { model.cleaner.openFullDiskAccessSettings() }
|
|
.buttonStyle(DSButtonStyle(kind: .secondary))
|
|
}
|
|
}
|
|
HStack(alignment: .top, spacing: DS.Space.s3) {
|
|
VStack(spacing: DS.Space.s3) {
|
|
CategoryCard(category: .system)
|
|
CategoryCard(category: .browser)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
VStack(spacing: DS.Space.s3) {
|
|
CategoryCard(category: .developer)
|
|
CategoryCard(category: .downloads)
|
|
CategoryCard(category: .trash)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
CleanerFooter()
|
|
}
|
|
.onAppear { model.cleaner.scan(ifNeeded: true) }
|
|
}
|
|
}
|
|
|
|
// MARK: - 汇总
|
|
|
|
private struct SummaryCard: View {
|
|
@Environment(AppModel.self) private var model
|
|
|
|
var body: some View {
|
|
let cleaner = model.cleaner
|
|
|
|
Card(padding: DS.Space.s3, spacing: DS.Space.s3) {
|
|
HStack(spacing: DS.Space.s1) {
|
|
Image(systemName: "sparkles").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
|
|
Text("磁盘清理").dsFont(.xs, weight: .semibold)
|
|
Spacer()
|
|
if let last = cleaner.lastScan {
|
|
TimelineView(.periodic(from: .now, by: 60)) { context in
|
|
Chip(text: "扫描于 " + Self.relative(last, now: context.date))
|
|
}
|
|
}
|
|
}
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
|
|
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
|
|
Text(verbatim: headline)
|
|
.dsFont(.xxl, weight: .semibold)
|
|
.monospacedDigit()
|
|
.foregroundStyle(DS.Palette.textPrimary)
|
|
Text(subline)
|
|
.dsFont(.sm)
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
if let disk = model.store.disk {
|
|
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
|
ProgressTrack(fraction: disk.usedFraction)
|
|
Text(verbatim: "\(disk.volumeName) 可用 \(Format.bytes(disk.available, base: .decimal)) · 共 \(Format.bytes(disk.total, base: .decimal))")
|
|
.dsFont(.xs)
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
}
|
|
}
|
|
|
|
if cleaner.isConfirming {
|
|
InfoBanner(icon: "exclamationmark.triangle.fill", text: confirmationText, tone: .warning) {
|
|
HStack(spacing: DS.Space.s2) {
|
|
Button("取消") { cleaner.cancelClean() }
|
|
.buttonStyle(DSButtonStyle(kind: .secondary))
|
|
Button("确认清理") { cleaner.confirmClean() }
|
|
.buttonStyle(DSButtonStyle(kind: .primary))
|
|
}
|
|
}
|
|
} else {
|
|
HStack(spacing: DS.Space.s2) {
|
|
Button { cleaner.scan() } label: {
|
|
Label(cleaner.phase == .scanning ? "扫描中…" : "重新扫描", systemImage: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(DSButtonStyle(kind: .secondary))
|
|
.disabled(cleaner.isBusy)
|
|
|
|
Button { cleaner.requestClean() } label: {
|
|
Label(cleaner.phase == .cleaning ? "清理中…" : "清理所选 \(Format.bytes(cleaner.selectedBytes, base: .decimal))",
|
|
systemImage: "trash")
|
|
}
|
|
.buttonStyle(DSButtonStyle(kind: .primary))
|
|
.disabled(cleaner.isBusy || cleaner.selectedBytes == 0)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var headline: String {
|
|
let cleaner = model.cleaner
|
|
if cleaner.phase == .finished { return Format.bytes(cleaner.report?.freedBytes ?? 0, base: .decimal) }
|
|
return cleaner.scans.isEmpty ? "扫描中" : Format.bytes(cleaner.totalBytes, base: .decimal)
|
|
}
|
|
|
|
private var subline: String {
|
|
let cleaner = model.cleaner
|
|
switch cleaner.phase {
|
|
case .idle, .scanning: return "正在计算可清理空间"
|
|
case .cleaning: return "正在清理…"
|
|
case .ready: return "可清理 · 已选 \(cleaner.selectedScans.count) 项"
|
|
case .finished:
|
|
guard let report = cleaner.report else { return "" }
|
|
var parts = ["已释放"]
|
|
if report.trashedBytes > 0 { parts.append("另有 \(Format.bytes(report.trashedBytes, base: .decimal)) 移到废纸篓") }
|
|
if report.skippedCount > 0 { parts.append("跳过 \(report.skippedCount) 项") }
|
|
if !report.failures.isEmpty { parts.append("\(report.failures.count) 项失败") }
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
}
|
|
|
|
private var confirmationText: String {
|
|
let cleaner = model.cleaner
|
|
let count = cleaner.selectedScans.reduce(0) { $0 + $1.items.count }
|
|
let trashNote = model.settings.cleanPrefersTrash ? "所有内容先移到废纸篓。" : "缓存与日志直接删除,下载内容移到废纸篓。"
|
|
return "将清理 \(count) 个项目,共 \(Format.bytes(cleaner.selectedBytes, base: .decimal))。\(trashNote)"
|
|
}
|
|
|
|
private static func relative(_ date: Date, now: Date) -> String {
|
|
let minutes = Int(now.timeIntervalSince(date) / 60)
|
|
return minutes < 1 ? "刚刚" : "\(Format.duration(minutes: minutes))前"
|
|
}
|
|
}
|
|
|
|
// MARK: - 系统维护
|
|
|
|
private struct MaintenanceCard: View {
|
|
@Environment(AppModel.self) private var model
|
|
|
|
var body: some View {
|
|
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
|
|
HStack(spacing: DS.Space.s1) {
|
|
Image(systemName: "wrench.and.screwdriver").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
|
|
Text("系统维护").dsFont(.xs, weight: .semibold)
|
|
}
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
|
|
MaintenanceButton(command: .flushDNS, icon: "network", title: "刷新 DNS 缓存")
|
|
MaintenanceButton(command: .purgeMemory, icon: "memorychip", title: "释放内存")
|
|
|
|
Text(model.helper.isReady ? "由辅助工具执行" : "需要管理员授权")
|
|
.dsFont(.xs)
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MaintenanceButton: View {
|
|
@Environment(AppModel.self) private var model
|
|
let command: MaintenanceCommand
|
|
let icon: String
|
|
let title: String
|
|
@State private var hovering = false
|
|
|
|
var body: some View {
|
|
let maintenance = model.maintenance
|
|
let isRunning = maintenance.running == command
|
|
let outcome = maintenance.outcomes[command]
|
|
|
|
Button {
|
|
Task { await maintenance.run(command) }
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
|
|
HStack(spacing: DS.Space.s2) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
|
|
.frame(width: DS.Size.iconStandalone)
|
|
Text(title).dsFont(.sm, weight: .medium)
|
|
Spacer(minLength: 0)
|
|
if isRunning {
|
|
Text("执行中").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
|
}
|
|
}
|
|
.foregroundStyle(DS.Palette.textPrimary)
|
|
if let outcome {
|
|
Text(outcome.text)
|
|
.dsFont(.xs)
|
|
.foregroundStyle(outcome.isError ? DS.Palette.error : DS.Palette.success)
|
|
.lineLimit(1)
|
|
.padding(.leading, DS.Size.iconStandalone + DS.Space.s2)
|
|
}
|
|
}
|
|
.padding(.horizontal, DS.Space.s2)
|
|
.padding(.vertical, DS.Space.s2)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(hovering ? DS.Palette.surfaceHover : DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.md))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(maintenance.running != nil)
|
|
.onHover { hovering = $0 }
|
|
}
|
|
}
|
|
|
|
// MARK: - 分类
|
|
|
|
private struct CategoryCard: View {
|
|
@Environment(AppModel.self) private var model
|
|
let category: CleanCategory
|
|
|
|
var body: some View {
|
|
let scans = model.cleaner.scans(in: category)
|
|
let total = scans.filter(\.isCleanable).reduce(0) { $0 + $1.totalSize }
|
|
|
|
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
|
|
HStack {
|
|
Text(category.title).dsFont(.xs, weight: .semibold).foregroundStyle(DS.Palette.textSecondary)
|
|
Spacer()
|
|
if total > 0 {
|
|
Text(verbatim: Format.bytes(total, base: .decimal))
|
|
.dsFont(.xs, weight: .medium)
|
|
.monospacedDigit()
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
}
|
|
}
|
|
if scans.isEmpty {
|
|
ForEach(0..<placeholderRows, id: \.self) { _ in
|
|
PlaceholderLine().frame(height: DS.Size.controlHeight)
|
|
}
|
|
}
|
|
ForEach(Array(scans.enumerated()), id: \.element.id) { index, scan in
|
|
if index > 0 { HairlineDivider() }
|
|
RuleRow(scan: scan)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 扫描完成前占位,保持面板高度稳定
|
|
private var placeholderRows: Int {
|
|
RuleCatalog.rules().filter { $0.category == category }.count
|
|
}
|
|
}
|
|
|
|
private struct RuleRow: View {
|
|
@Environment(AppModel.self) private var model
|
|
let scan: RuleScan
|
|
@State private var expanded = false
|
|
|
|
var body: some View {
|
|
let cleaner = model.cleaner
|
|
let selected = cleaner.selection.contains(scan.id) && scan.isCleanable
|
|
|
|
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
|
HStack(spacing: DS.Space.s2) {
|
|
DSCheckbox(isOn: selected) { cleaner.toggle(scan) }
|
|
.disabled(!scan.isCleanable || cleaner.isBusy)
|
|
Image(systemName: scan.rule.symbol)
|
|
.font(.system(size: DS.TextSize.sm.rawValue))
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
.frame(width: DS.Size.iconStandalone)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(scan.rule.title)
|
|
.dsFont(.sm, weight: .medium)
|
|
.foregroundStyle(scan.blocked == nil ? DS.Palette.textPrimary : DS.Palette.textTertiary)
|
|
Text(statusText)
|
|
.dsFont(.xs)
|
|
.foregroundStyle(scan.blocked == nil ? DS.Palette.textTertiary : DS.Palette.warning)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: DS.Space.s1)
|
|
if scan.isCleanable {
|
|
Text(verbatim: Format.bytes(scan.totalSize, base: .decimal))
|
|
.dsFont(.sm, weight: .medium)
|
|
.monospacedDigit()
|
|
.foregroundStyle(DS.Palette.textPrimary)
|
|
Button { expanded.toggle() } label: {
|
|
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
|
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
.frame(width: DS.Size.iconStandalone, height: DS.Size.iconStandalone)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("查看包含的项目")
|
|
}
|
|
}
|
|
|
|
if expanded {
|
|
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
|
ForEach(scan.items.prefix(Self.previewLimit)) { item in
|
|
Button { cleaner.reveal(item) } label: {
|
|
HStack {
|
|
Text(item.url.lastPathComponent).dsFont(.xs).lineLimit(1).truncationMode(.middle)
|
|
Spacer(minLength: DS.Space.s2)
|
|
Text(verbatim: Format.bytes(item.size, base: .decimal)).dsFont(.xs).monospacedDigit()
|
|
}
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("在访达中显示")
|
|
}
|
|
if scan.items.count > Self.previewLimit {
|
|
Text(verbatim: "另有 \(scan.items.count - Self.previewLimit) 项")
|
|
.dsFont(.xs)
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
}
|
|
}
|
|
.padding(.leading, DS.Size.iconInline + DS.Size.iconStandalone + DS.Space.s2 * 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static let previewLimit = 6
|
|
|
|
private var statusText: String {
|
|
if let blocked = scan.blocked { return blocked.title }
|
|
if scan.items.isEmpty { return "无需清理" }
|
|
var text = "\(scan.items.count) 项"
|
|
if scan.skippedCount > 0 { text += " · \(scan.skippedCount) 项使用中" }
|
|
if scan.rule.policy == .trash { text += " · 移到废纸篓" }
|
|
return text
|
|
}
|
|
}
|
|
|
|
// MARK: - 底部
|
|
|
|
private struct CleanerFooter: View {
|
|
@Environment(AppModel.self) private var model
|
|
|
|
var body: some View {
|
|
@Bindable var settings = model.settings
|
|
|
|
HStack(spacing: DS.Space.s3) {
|
|
DSToggle(isOn: $settings.cleanPrefersTrash, label: "缓存也先移到废纸篓")
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("缓存也先移到废纸篓").dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
|
|
Text("可以恢复,但清空废纸篓前不会释放空间").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
|
}
|
|
Spacer()
|
|
Button("查看清理日志") { model.cleaner.revealLog() }
|
|
.buttonStyle(DSButtonStyle(kind: .ghost))
|
|
}
|
|
.padding(.horizontal, DS.Space.s1)
|
|
}
|
|
}
|