import Cleaner import HelperShared import Localization 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: tr("部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。"), tone: .warning) { Button(tr("去授权")) { 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(tr("磁盘清理")).dsFont(.xs, weight: .semibold) Spacer() if let last = cleaner.lastScan { TimelineView(.periodic(from: .now, by: 60)) { context in Chip(text: tr("扫描于 ") + 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: tr("\(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(tr("取消")) { cleaner.cancelClean() } .buttonStyle(DSButtonStyle(kind: .secondary)) Button(tr("确认清理")) { cleaner.confirmClean() } .buttonStyle(DSButtonStyle(kind: .primary)) } } } else { HStack(spacing: DS.Space.s2) { Button { cleaner.scan() } label: { Label(cleaner.phase == .scanning ? tr("扫描中…") : tr("重新扫描"), systemImage: "arrow.clockwise") } .buttonStyle(DSButtonStyle(kind: .secondary)) .disabled(cleaner.isBusy) Button { cleaner.requestClean() } label: { Label(cleaner.phase == .cleaning ? tr("清理中…") : tr("清理所选 \(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 ? tr("扫描中") : Format.bytes(cleaner.totalBytes, base: .decimal) } private var subline: String { let cleaner = model.cleaner switch cleaner.phase { case .idle, .scanning: return tr("正在计算可清理空间") case .cleaning: return tr("正在清理…") case .ready: return tr("可清理 · 已选 \(cleaner.selectedScans.count) 项") case .finished: guard let report = cleaner.report else { return "" } var parts = [tr("已释放")] if report.trashedBytes > 0 { parts.append(tr("另有 \(Format.bytes(report.trashedBytes, base: .decimal)) 移到废纸篓")) } if report.skippedCount > 0 { parts.append(tr("跳过 \(report.skippedCount) 项")) } if !report.failures.isEmpty { parts.append(tr("\(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 ? tr("所有内容先移到废纸篓。") : tr("缓存与日志直接删除,下载内容移到废纸篓。") return tr("将清理 \(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 ? tr("刚刚") : tr("\(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(tr("系统维护")).dsFont(.xs, weight: .semibold) } .foregroundStyle(DS.Palette.textSecondary) MaintenanceButton(command: .flushDNS, icon: "network", title: tr("刷新 DNS 缓存")) MaintenanceButton(command: .purgeMemory, icon: "memorychip", title: tr("释放内存")) Text(model.helper.isReady ? tr("由辅助工具执行") : tr("需要管理员授权")) .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(tr("执行中")).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.. 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(tr("查看包含的项目")) } } 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(tr("在访达中显示")) } if scan.items.count > Self.previewLimit { Text(verbatim: tr("另有 \(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 tr("无需清理") } var text = tr("\(scan.items.count) 项") if scan.skippedCount > 0 { text += tr(" · \(scan.skippedCount) 项使用中") } if scan.rule.policy == .trash { text += tr(" · 移到废纸篓") } 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: tr("缓存也先移到废纸篓")) VStack(alignment: .leading, spacing: 0) { Text(tr("缓存也先移到废纸篓")).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary) Text(tr("可以恢复,但清空废纸篓前不会释放空间")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) } Spacer() Button(tr("查看清理日志")) { model.cleaner.revealLog() } .buttonStyle(DSButtonStyle(kind: .ghost)) } .padding(.horizontal, DS.Space.s1) } }