Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

318 lines
12 KiB
Swift

import SwiftUI
import MacToolsPluginKit
struct DiskCleanDetailView: View {
@ObservedObject var controller: DiskCleanController
private let localization: PluginLocalization
private let showsHeader: Bool
private let contentPadding: CGFloat
private let minimumContentHeight: CGFloat
init(
controller: DiskCleanController,
localization: PluginLocalization = PluginLocalization(bundle: .main),
showsHeader: Bool = true,
contentPadding: CGFloat = 20,
minimumContentHeight: CGFloat = 420
) {
self.controller = controller
self.localization = localization
self.showsHeader = showsHeader
self.contentPadding = contentPadding
self.minimumContentHeight = minimumContentHeight
}
var body: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
if showsHeader {
header
}
choiceControls
actionBar
scanLog
statusSummary
candidateList
}
.padding(contentPadding)
.frame(maxWidth: .infinity, minHeight: minimumContentHeight, alignment: .topLeading)
}
private var snapshot: DiskCleanControllerSnapshot {
controller.snapshot
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text(localization.string("detail.title", defaultValue: "磁盘清理"))
.font(PluginSettingsTheme.Typography.pageTitle)
Text(snapshot.errorMessage ?? snapshot.subtitle(localization: localization))
.font(PluginSettingsTheme.Typography.pageDescription.weight(.medium))
.foregroundStyle(snapshot.errorMessage == nil ? Color.secondary : Color.red)
}
}
private var choiceControls: some View {
HStack(spacing: 16) {
ForEach(DiskCleanChoice.allCases) { choice in
Toggle(
choice.title(localization: localization),
isOn: Binding(
get: { snapshot.selectedChoices.contains(choice) },
set: { controller.setChoice(choice, isSelected: $0) }
)
)
.toggleStyle(.checkbox)
.disabled(snapshot.isBusy)
}
Spacer()
}
}
private var actionBar: some View {
HStack(spacing: 10) {
Button {
controller.scan()
} label: {
Label(localization.string("detail.action.scan", defaultValue: "扫描"), systemImage: "magnifyingglass")
}
.disabled(!snapshot.canScan)
Button {
controller.cleanSelected(candidateIDs: cleanableCandidateIDs)
} label: {
Label(localization.string("detail.action.clean", defaultValue: "清理"), systemImage: "trash")
}
.disabled(!snapshot.canClean)
if snapshot.isBusy {
Button {
controller.cancelCurrentOperation()
} label: {
Label(localization.string("detail.action.stop", defaultValue: "停止"), systemImage: "xmark.circle")
}
}
}
}
private var scanLog: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(localization.string("detail.scanLog.title", defaultValue: "扫描日志"))
.font(PluginSettingsTheme.Typography.sectionTitle)
Spacer()
if snapshot.phase == .scanning {
ProgressView()
.controlSize(.small)
.scaleEffect(0.75)
}
}
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 6) {
if snapshot.scanLogEntries.isEmpty {
Text(localization.string("detail.scanLog.empty", defaultValue: "点击扫描后显示实时进度"))
.font(PluginSettingsTheme.Typography.secondaryLabel)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
ForEach(snapshot.scanLogEntries) { entry in
logRow(entry)
.id(entry.id)
}
}
}
.padding(10)
}
.frame(minHeight: 118, maxHeight: 160)
.pluginSettingsCardBackground(.recessed)
.onChange(of: snapshot.scanLogEntries.last?.id) {
guard let id = snapshot.scanLogEntries.last?.id else { return }
proxy.scrollTo(id, anchor: .bottom)
}
}
}
}
private func logRow(_ entry: DiskCleanScanLogEntry) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: logIconName(entry.tone))
.font(PluginSettingsTheme.Typography.statusBadge)
.foregroundStyle(logColor(entry.tone))
.frame(width: 14, height: 14)
Text(entry.text)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.textSelection(.enabled)
.lineLimit(3)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var statusSummary: some View {
if let scanResult = snapshot.scanResult {
HStack(spacing: 16) {
summaryTile(
title: localization.string("detail.summary.cleanable", defaultValue: "可清理"),
value: itemCountText(scanResult.cleanableCandidates.count)
)
summaryTile(
title: localization.string("detail.summary.estimatedRelease", defaultValue: "预计释放"),
value: byteText(scanResult.cleanableSizeBytes)
)
summaryTile(
title: localization.string("detail.summary.protected", defaultValue: "已保护"),
value: itemCountText(scanResult.protectedCount)
)
}
}
if let executionResult = snapshot.executionResult {
HStack(spacing: 16) {
summaryTile(
title: localization.string("detail.summary.removed", defaultValue: "已删除"),
value: itemCountText(executionResult.removedCount)
)
summaryTile(
title: localization.string("detail.summary.skipped", defaultValue: "已跳过"),
value: itemCountText(executionResult.skippedCount)
)
summaryTile(
title: localization.string("detail.summary.failed", defaultValue: "失败"),
value: itemCountText(executionResult.failedCount)
)
summaryTile(
title: localization.string("detail.summary.released", defaultValue: "已释放"),
value: byteText(executionResult.reclaimedBytes)
)
}
}
}
private func summaryTile(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(PluginSettingsTheme.Typography.secondaryLabel)
.foregroundStyle(.secondary)
Text(value)
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
}
.frame(minWidth: 96, alignment: .leading)
}
@ViewBuilder
private var candidateList: some View {
if let scanResult = snapshot.scanResult {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(scanResult.candidates) { candidate in
candidateRow(candidate)
Divider()
}
}
}
.overlay {
if scanResult.candidates.isEmpty {
Text(localization.string("detail.candidates.empty", defaultValue: "没有发现可清理项目"))
.font(PluginSettingsTheme.Typography.rowTitle)
.foregroundStyle(.secondary)
}
}
} else {
EmptyView()
}
}
private func candidateRow(_ candidate: DiskCleanCandidate) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: candidate.safety.isCleanable ? "checkmark.circle.fill" : "shield.fill")
.foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary)
.frame(width: 18)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(candidate.title)
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
Spacer(minLength: 12)
Text(byteText(candidate.sizeBytes))
.font(PluginSettingsTheme.Typography.secondaryLabel)
.foregroundStyle(.secondary)
}
Text(candidate.path)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
Text(safetyText(candidate.safety))
.font(PluginSettingsTheme.Typography.secondaryLabel)
.foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary)
}
}
.padding(.vertical, 9)
}
private var cleanableCandidateIDs: Set<DiskCleanCandidate.ID> {
Set(snapshot.scanResult?.cleanableCandidates.map(\.id) ?? [])
}
private func safetyText(_ safety: DiskCleanSafetyStatus) -> String {
switch safety {
case .allowed:
return localization.string("safety.allowed", defaultValue: "允许清理")
case let .whitelisted(rule):
return localization.format("safety.whitelisted", defaultValue: "白名单保护:%@", rule)
case let .protected(reason):
return localization.format("safety.protected", defaultValue: "敏感数据保护:%@", reason)
case let .invalid(reason):
return localization.format("safety.invalid", defaultValue: "路径安全保护:%@", reason)
case let .requiresAdmin(reason):
return localization.format("safety.requiresAdmin", defaultValue: "需要管理员权限:%@", reason)
case let .inUse(processName):
return localization.format("safety.inUse", defaultValue: "正在使用:%@", processName)
}
}
private func itemCountText(_ count: Int) -> String {
localization.format("item.count", defaultValue: "%d 项", count)
}
private func byteText(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
private func logIconName(_ tone: DiskCleanScanLogTone) -> String {
switch tone {
case .info:
return "circle"
case .success:
return "checkmark.circle.fill"
case .warning:
return "shield.fill"
case .error:
return "xmark.octagon.fill"
}
}
private func logColor(_ tone: DiskCleanScanLogTone) -> Color {
switch tone {
case .info:
return .secondary
case .success:
return .green
case .warning:
return .orange
case .error:
return .red
}
}
}