Files
ngfchl 0df8013d3e feat: 移除内置播放器,统一外部播放体验
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器
- 播放器选择器优化为 Grid 布局,显示真实应用图标
- 右键菜单合并为"播放"菜单,直接列出所有播放器
- 详情页剧集列表增加右键菜单
- 媒体库所有上下文菜单统一播放入口
- 修复启动时自动清理已删除文件的秒传记录
- 添加 prepareAndPerformFastTransfer 静态方法
2026-07-18 00:50:21 +08:00

3308 lines
165 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 AVFoundation
import AVKit
import CoreImage.CIFilterBuiltins
import SwiftUI
import UniformTypeIdentifiers
import WebKit
struct ContentView: View {
@StateObject private var model = AppModel()
#if DEBUG
@State private var injectionRevision = 0
#endif
var body: some View {
Group {
if model.isSignedIn {
WorkspaceView(model: model)
} else {
LoginView(model: model)
}
}
.alert("请求失败", isPresented: Binding(get: { !model.errorMessage.isEmpty }, set: { if !$0 { model.errorMessage = "" } })) {
Button("确定", role: .cancel) { model.errorMessage = "" }
} message: {
Text(model.errorMessage)
}
#if DEBUG
// InjectionIII posts this after replacing an implementation. Recreating the view
// hierarchy makes SwiftUI evaluate the newly injected view bodies immediately.
.id(injectionRevision)
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("INJECTION_BUNDLE_NOTIFICATION"))) { _ in
DispatchQueue.main.async {
injectionRevision &+= 1
}
}
#endif
}
}
private func proxyConfigurationIsValid(host: String, port: String) -> Bool {
let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPort = port.trimmingCharacters(in: .whitespacesAndNewlines)
if normalizedHost.isEmpty && normalizedPort.isEmpty { return true }
guard !normalizedHost.isEmpty, let value = Int(normalizedPort) else { return false }
return (1...65535).contains(value)
}
private struct LoginView: View {
@ObservedObject var model: AppModel
let autoStartQR: Bool
@State private var mode = 0
init(model: AppModel, autoStartQR: Bool = true) {
self.model = model
self.autoStartQR = autoStartQR
}
var body: some View {
ZStack {
AppBackdrop()
HStack(spacing: 0) {
LoginBrandPanel()
LoginFormPanel(model: model, mode: $mode)
}
.frame(width: 920, height: 610)
.clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 34, style: .continuous)
.stroke(.white.opacity(0.52), lineWidth: 1)
}
.shadow(color: .black.opacity(0.22), radius: 42, y: 22)
.padding(30)
}
.frame(minWidth: 980, minHeight: 680)
.task {
guard autoStartQR, model.qrPayload.isEmpty, !model.isBusy else { return }
await model.startQRLogin()
}
}
}
private struct LoginBrandPanel: View {
var body: some View {
ZStack(alignment: .topLeading) {
LinearGradient(colors: [AppTheme.orange, AppTheme.orangeDeep, Color(red: 0.45, green: 0.08, blue: 0.03)], startPoint: .topLeading, endPoint: .bottomTrailing)
Circle().fill(.white.opacity(0.16)).frame(width: 260, height: 260).blur(radius: 10).offset(x: -95, y: -80)
Circle().fill(.yellow.opacity(0.18)).frame(width: 210, height: 210).blur(radius: 16).offset(x: 230, y: 390)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 13) {
BrandMark(size: 50)
.padding(8)
.background(.white.opacity(0.16), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text("光鸭云盘").font(.title3.weight(.bold))
Text("GUANGYA CLOUD").font(.system(size: 9, weight: .bold, design: .rounded)).tracking(1.6).foregroundStyle(.white.opacity(0.66))
}
}
Spacer()
Text("文件,")
Text("在你身边。")
Text("轻松管理每一份重要内容。")
.font(.title3.weight(.medium))
.foregroundStyle(.white.opacity(0.78))
.padding(.top, 16)
VStack(alignment: .leading, spacing: 15) {
BrandFeature(icon: "square.stack.3d.up.fill", title: "云端文件集中管理")
BrandFeature(icon: "arrow.triangle.2.circlepath", title: "多端同步,随时访问")
BrandFeature(icon: "lock.shield.fill", title: "安全连接,安心使用")
}
.padding(.top, 40)
Spacer()
HStack(spacing: 8) {
Circle().fill(Color.green).frame(width: 8, height: 8).shadow(color: .green.opacity(0.6), radius: 6)
Text("服务正常运行").font(.caption.weight(.medium)).foregroundStyle(.white.opacity(0.74))
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(.white.opacity(0.12), in: Capsule())
}
.font(.system(size: 44, weight: .black, design: .rounded))
.foregroundStyle(.white)
.padding(42)
}
.frame(width: 380)
.frame(maxHeight: .infinity, alignment: .leading)
}
}
private struct BrandFeature: View {
let icon: String
let title: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.callout.weight(.semibold))
.frame(width: 30, height: 30)
.background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
Text(title).font(.callout.weight(.medium)).foregroundStyle(.white.opacity(0.88))
}
}
}
private struct LoginFormPanel: View {
@ObservedObject var model: AppModel
@Binding var mode: Int
var body: some View {
VStack(spacing: 0) {
Spacer()
VStack(alignment: .leading, spacing: 22) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 7) {
Text("欢迎回来").font(.system(size: 31, weight: .bold, design: .rounded))
Text("登录后开始管理你的云端文件").font(.callout).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "person.crop.circle.badge.checkmark")
.font(.title2)
.foregroundStyle(Color.orange)
}
HStack(spacing: 4) {
loginModeButton(title: "扫码登录", icon: "qrcode", tag: 0)
loginModeButton(title: "手机号", icon: "iphone", tag: 1)
}
.padding(4)
.background(Color.black.opacity(0.055), in: Capsule())
Group {
if mode == 0 {
QRLoginPanel(model: model)
} else {
SMSLoginPanel(model: model)
}
}
.frame(maxWidth: .infinity)
}
.padding(34)
.frame(width: 452)
.softCard(radius: 24)
Spacer()
HStack(spacing: 5) {
Image(systemName: "checkmark.seal.fill")
Text("光鸭云盘安全登录")
}
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.bottom, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.clear)
}
private func loginModeButton(title: String, icon: String, tag: Int) -> some View {
Button { withAnimation(.easeInOut(duration: 0.18)) { mode = tag } } label: {
Label(title, systemImage: icon)
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.foregroundStyle(mode == tag ? Color.primary : Color.secondary)
.background(mode == tag ? Color(nsColor: .windowBackgroundColor) : Color.clear, in: Capsule())
.shadow(color: mode == tag ? .black.opacity(0.08) : .clear, radius: 3, y: 1)
}
.buttonStyle(.plain)
}
}
private struct QRLoginPanel: View {
@ObservedObject var model: AppModel
var body: some View {
VStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.orange.opacity(0.08))
.frame(width: 232, height: 232)
if model.qrPayload.isEmpty {
VStack(spacing: 13) {
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 58, weight: .light))
.foregroundStyle(Color.orange.opacity(0.78))
Text("准备登录")
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
}
} else {
QRCodeImage(payload: model.qrPayload)
.frame(width: 190, height: 190)
.padding(14)
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(color: .black.opacity(0.12), radius: 15, y: 7)
}
}
HStack(spacing: 8) {
Circle().fill(model.qrPayload.isEmpty ? Color.secondary : Color.green).frame(width: 7, height: 7)
Text(model.qrStatus).font(.callout).foregroundStyle(.secondary)
}
HStack(spacing: 8) {
Text("请使用光鸭 App 扫码登录")
.font(.caption)
.foregroundStyle(.tertiary)
Spacer()
Button { Task { await model.startQRLogin() } } label: {
if model.isBusy { ProgressView().controlSize(.small) }
else { Image(systemName: "arrow.clockwise") }
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("刷新二维码")
.disabled(model.isBusy)
}
Text("二维码会在打开登录页时自动生成")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
private struct SMSLoginPanel: View {
@ObservedObject var model: AppModel
@FocusState private var focusedField: Field?
private enum Field { case phone, code }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
LoginFieldLabel(title: "手机号", icon: "phone.fill")
TextField("+86 13800138000", text: $model.phoneNumber)
.textFieldStyle(.plain)
.focused($focusedField, equals: .phone)
.padding(.horizontal, 13)
.frame(height: 40)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 8).stroke(focusedField == .phone ? Color.orange : Color.black.opacity(0.12), lineWidth: 1) }
.textContentType(.telephoneNumber)
LoginFieldLabel(title: "短信验证码", icon: "number.square.fill")
HStack(spacing: 10) {
TextField("请输入验证码", text: $model.verificationCode)
.textFieldStyle(.plain)
.focused($focusedField, equals: .code)
.padding(.horizontal, 13)
.frame(height: 40)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 8).stroke(focusedField == .code ? Color.orange : Color.black.opacity(0.12), lineWidth: 1) }
Button {
Task { await model.sendVerificationCode() }
} label: {
Text(model.codeCountdown > 0 ? "\(model.codeCountdown)s" : "获取验证码")
.frame(width: 94, height: 40)
}
.buttonStyle(.bordered)
.disabled(model.isBusy || model.codeCountdown > 0)
}
Button {
Task { await model.verifyAndSignIn() }
} label: {
HStack(spacing: 8) {
if model.isBusy { ProgressView().controlSize(.small) }
Text("登录")
Image(systemName: "arrow.right")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(Color.orange)
.disabled(model.isBusy)
if !model.statusMessage.isEmpty {
Label(model.statusMessage, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private struct LoginFieldLabel: View {
let title: String
let icon: String
var body: some View {
Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
}
}
struct WorkspaceSidebar: View {
@ObservedObject var model: AppModel
@Binding var selectedTool: WorkspaceTool?
let isCompact: Bool
var body: some View {
Group {
if isCompact { compactSidebar }
else { expandedSidebar }
}
}
private var expandedSidebar: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 12) {
BrandMark(size: 34)
.padding(7)
.background(Color.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 15, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text("光鸭云盘").font(.headline.weight(.bold))
Text("Cloud Workspace").font(.system(size: 9, weight: .bold, design: .rounded)).tracking(1.1).foregroundStyle(.secondary)
}
Spacer()
}
.padding(.top, 8)
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 5) {
ForEach([WorkspaceSection.files, .recentViewed, .recentRestored]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } }
}
Divider().padding(.horizontal, 8)
VStack(alignment: .leading, spacing: 5) {
ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in
SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section }
}
}
Divider().padding(.horizontal, 8)
VStack(alignment: .leading, spacing: 5) {
ForEach([WorkspaceSection.photos, .videos, .audio, .documents]) { section in
SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section }
}
}
Divider().padding(.horizontal, 8)
VStack(alignment: .leading, spacing: 5) {
ForEach(WorkspaceTool.allCases.filter { $0 != .media && $0 != .tmdb }) { tool in
ToolSidebarItem(tool: tool, selected: selectedTool == tool) {
if tool == .rename {
model.batchRenameTarget = nil
model.batchRenameSelection = []
}
selectedTool = tool
}
}
}
}
.padding(.trailing, 4)
}
.frame(maxHeight: .infinity)
.clipped()
HStack(spacing: 11) {
Menu {
Text("会员:\(model.user.memberLevel)")
Text("账号:\(model.user.phone.isEmpty ? "未提供" : model.user.phone)")
Text("空间:\(model.user.capacityText)")
Divider()
Button("退出登录", role: .destructive) { model.logout() }
} label: {
HStack(spacing: 11) {
Circle().fill(Color.orange.opacity(0.18)).frame(width: 38, height: 38).overlay { Text(String(model.user.name.prefix(1))).font(.headline.weight(.bold)).foregroundStyle(.orange) }
VStack(alignment: .leading, spacing: 2) { Text(model.user.name).font(.callout.weight(.semibold)).lineLimit(1); Text(model.user.memberLevel).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
}
.contentShape(Rectangle())
}.menuStyle(.borderlessButton).help("查看账户与空间信息")
Spacer()
Button { model.logout() } label: { Image(systemName: "rectangle.portrait.and.arrow.right") }.buttonStyle(.plain).help("退出登录")
}
.padding(12)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.padding(16)
.task { await model.loadMediaLibraries() }
}
private var compactSidebar: some View {
VStack(spacing: 10) {
BrandMark(size: 26)
.padding(7).background(Color.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 9))
.help("光鸭云盘")
Divider()
compactSectionButton(.files)
compactSectionButton(.recentViewed)
compactSectionButton(.recentRestored)
Divider().padding(.vertical, 2)
compactSectionButton(.cloud)
compactSectionButton(.shares)
compactSectionButton(.recycle)
Divider().padding(.vertical, 2)
ForEach([WorkspaceSection.photos, .videos, .audio, .documents]) { compactSectionButton($0) }
Divider().padding(.vertical, 2)
ForEach(WorkspaceTool.allCases.filter { $0 != .media && $0 != .tmdb }) { tool in
compactToolButton(tool)
}
Spacer(minLength: 6)
Menu {
Text(model.user.name)
if !model.user.phone.isEmpty { Text(model.user.phone) }
Divider()
Button("退出登录", role: .destructive) { model.logout() }
} label: {
Text(String(model.user.name.prefix(1))).font(.caption.weight(.bold)).foregroundStyle(.orange)
.frame(width: 32, height: 32).background(Color.orange.opacity(0.16), in: Circle())
}
.menuStyle(.borderlessButton).help("账户:\(model.user.name)")
}
.padding(.vertical, 14).frame(width: 64).frame(maxHeight: .infinity)
.background(.regularMaterial)
.task { await model.loadMediaLibraries() }
}
private func compactSectionButton(_ section: WorkspaceSection) -> some View {
let isSelected = selectedTool == nil && model.section == section
return Button { selectedTool = nil; model.section = section } label: {
Image(systemName: section.icon).frame(width: 34, height: 34)
.foregroundStyle(isSelected ? Color.orange : Color.secondary)
.background(isSelected ? Color.orange.opacity(0.16) : .clear, in: RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain).help(section.rawValue).compactHoverHint(section.rawValue).accessibilityLabel(section.rawValue)
}
private func compactToolButton(_ tool: WorkspaceTool) -> some View {
let isSelected = selectedTool == tool
return Button { selectedTool = tool } label: {
Image(systemName: tool.icon).frame(width: 34, height: 34)
.foregroundStyle(isSelected ? Color.orange : Color.secondary)
.background(isSelected ? Color.orange.opacity(0.16) : .clear, in: RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain).help(tool.rawValue).compactHoverHint(tool.rawValue).accessibilityLabel(tool.rawValue)
}
}
private struct MediaLibrarySidebarRow: View {
let title: String
let icon: String
let isSelected: Bool
var status: String? = nil
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 9) {
Image(systemName: icon).frame(width: 20).foregroundStyle(isSelected ? AppTheme.orange : .secondary)
Text(title).font(.callout).lineLimit(1)
Spacer()
if let status { Text(status).font(.caption2.weight(.semibold)).foregroundStyle(AppTheme.orange) }
}
.padding(.horizontal, 9).frame(height: 32)
.background(isSelected ? AppTheme.orange.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private struct SidebarItem: View {
let section: WorkspaceSection
let selected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 11) {
Image(systemName: section.icon)
.font(.system(size: 15, weight: .semibold))
.frame(width: 28, height: 28)
.foregroundStyle(selected ? .white : .orange)
.background(selected ? Color.orange : Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
Text(section.rawValue).font(.callout.weight(selected ? .semibold : .regular))
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.foregroundStyle(selected ? .primary : .secondary)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(selected ? Color.orange.opacity(0.14) : Color.clear, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.focusable(false)
}
}
private struct ToolSidebarItem: View {
let tool: WorkspaceTool
let selected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 9) {
Image(systemName: tool.icon)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(selected ? .white : .orange)
.frame(width: 26, height: 26)
.background(selected ? Color.orange : Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 9, style: .continuous))
Text(tool.rawValue).font(.caption.weight(selected ? .semibold : .regular)).lineLimit(1)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.padding(.horizontal, 10).padding(.vertical, 6)
.background(selected ? Color.orange.opacity(0.13) : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.focusable(false)
}
}
struct FilesBrowser: View {
@ObservedObject var model: AppModel
@Binding var selectedFileIDs: Set<String>
@Binding var globalSearchText: String
@Binding var renameFile: CloudFile?
@Binding var isCreatingFolder: Bool
@Binding var isImporting: Bool
@Binding var isShowingSettings: Bool
@Binding var isShowingDuplicates: Bool
@Binding var isDualPanel: Bool
let onPreview: (CloudFile) -> Void
let onOpenTool: (WorkspaceTool) -> Void
let onShowInspector: () -> Void
var dualPanelContent: AnyView? = nil // Optional dual-panel view to replace file list
@State private var showBatchDeleteConfirmation = false
@State private var selectionAnchorID: String?
@State private var keyboardFocusID: String?
@State private var tmdbTarget: CloudFile?
// The title-bar search is a separate whole-drive result set; normal browsing
// keeps the server's current-folder order intact.
private var filteredFiles: [CloudFile] {
globalSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? model.files : model.globalFileSearchResults
}
private var isShowingGlobalSearchResults: Bool { !globalSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
private var fileCount: Int { filteredFiles.filter { !$0.isDirectory }.count }
private var folderCount: Int { filteredFiles.filter(\.isDirectory).count }
private var totalSizeText: String {
let total = filteredFiles.compactMap(\.size).reduce(Int64(0), +)
guard total > 0 else { return "--" }
let formatter = ByteCountFormatter(); formatter.countStyle = .file
return formatter.string(fromByteCount: total)
}
var body: some View {
VStack(spacing: 12) {
browserHeader
content
statusBar
}
.padding(16)
.background(Color.clear)
.onMoveCommand { direction in moveKeyboardSelection(direction: direction) }
.onCommand(#selector(NSResponder.scrollPageUp(_:))) { moveKeyboardSelection(page: -1) }
.onCommand(#selector(NSResponder.scrollPageDown(_:))) { moveKeyboardSelection(page: 1) }
.onCommand(#selector(NSResponder.moveToBeginningOfDocument(_:))) { selectKeyboardIndex(0) }
.onCommand(#selector(NSResponder.moveToEndOfDocument(_:))) { selectKeyboardIndex(max(0, filteredFiles.count - 1)) }
.sheet(item: $tmdbTarget) { TMDBRecognizeSheet(model: model, file: $0) }
.confirmationDialog("确认删除已选 \(selectedFileIDs.count) 项?", isPresented: $showBatchDeleteConfirmation) {
Button("删除 \(selectedFileIDs.count) 项", role: .destructive) {
let ids = selectedFileIDs
selectedFileIDs = []
selectionAnchorID = nil
Task { await model.deleteSelectedFiles(ids) }
}
Button("取消", role: .cancel) { }
} message: { Text("选中的文件及文件夹将被删除。文件夹会连同其内容一起删除,且此操作不可撤销。") }
}
private var browserHeader: some View {
HStack(spacing: 12) {
if isShowingGlobalSearchResults {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.orange)
Text("搜索结果:\(globalSearchText)")
.font(.callout.weight(.semibold))
.lineLimit(1)
Button { clearGlobalSearchResults() } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("返回全部文件")
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
breadcrumb
}
Spacer(minLength: 12)
browserActions
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 4).padding(.vertical, 4)
}
private var breadcrumb: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
Button { Task { await model.navigateToRoot() } } label: {
Image(systemName: "house.fill").font(.system(size: 11, weight: .bold))
}
.buttonStyle(.plain).foregroundStyle(.orange)
if model.section == .files && !model.folderPath.isEmpty {
ForEach(Array(model.folderPath.enumerated()), id: \.element.id) { index, folder in
Image(systemName: "chevron.right").font(.system(size: 8, weight: .bold)).foregroundStyle(.tertiary)
Button(folder.name) { Task { await model.navigateToFolder(at: index) } }
.buttonStyle(.plain)
.font(.caption.weight(index == model.folderPath.count - 1 ? .bold : .medium))
.foregroundStyle(index == model.folderPath.count - 1 ? .primary : .secondary)
.lineLimit(1)
.padding(.horizontal, 8).padding(.vertical, 5)
.background(index == model.folderPath.count - 1 ? Color.orange.opacity(0.12) : Color.clear, in: Capsule())
}
} else {
Text(model.section == .files ? "全部文件" : "按类型整理的文件").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10).padding(.vertical, 5)
.background(.thinMaterial, in: Capsule())
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var statusBar: some View {
HStack(spacing: 12) {
Label("\(globalSearchText.isEmpty ? "本页" : "搜索结果")文件 \(fileCount)", systemImage: "doc.fill").foregroundStyle(.secondary)
Label("\(globalSearchText.isEmpty ? "本页" : "搜索结果")文件夹 \(folderCount)", systemImage: "folder.fill").foregroundStyle(.secondary)
Label("\(globalSearchText.isEmpty ? "本页" : "搜索结果")大小 \(totalSizeText)", systemImage: "externaldrive.fill").foregroundStyle(.secondary)
if model.isLoadingFiles || model.isLoadingFolderSizes || model.isBusy { HStack(spacing: 6) { ProgressView().controlSize(.small); Text(model.statusMessage.isEmpty ? "正在同步…" : model.statusMessage).font(.caption).foregroundStyle(.secondary) } }
Spacer(minLength: 4)
paginationControls
}
.font(.caption)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(.white.opacity(0.35), lineWidth: 1) }
}
private var browserActions: some View {
HStack(spacing: 7) {
if !selectedFileIDs.isEmpty {
Text("已选 \(selectedFileIDs.count) 项").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Button(role: .destructive) { showBatchDeleteConfirmation = true } label: { Image(systemName: "trash") }
.buttonStyle(.bordered).help("删除选中的 \(selectedFileIDs.count) 项")
}
if model.section == .files && !isShowingGlobalSearchResults {
Button { isImporting = true } label: { Label("上传", systemImage: "arrow.up") }
.buttonStyle(.borderedProminent).tint(.orange)
Button { isCreatingFolder = true } label: { Image(systemName: "folder.badge.plus") }
.buttonStyle(.bordered).help("新建文件夹")
Button { withAnimation(.easeInOut(duration: 0.2)) { isDualPanel.toggle() } } label: {
Image(systemName: isDualPanel ? "rectangle.split.2x1" : "rectangle.on.rectangle")
}
.buttonStyle(.bordered)
.help(isDualPanel ? "切换到单面板" : "左右双面板浏览")
}
Button { Task { await model.refresh() } } label: {
if model.isLoadingFiles { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") }
}
.buttonStyle(.bordered)
.help(model.isLoadingFiles ? "重新加载当前列表" : "刷新")
.accessibilityLabel("刷新文件列表")
.disabled(model.isBusy)
moreMenu
}
.fixedSize()
}
private var content: some View {
Group {
if let dualContent = dualPanelContent {
dualContent
} else if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section).softCard(radius: 24).padding(.horizontal, 10) }
else if model.isLoadingFiles || model.isGlobalFileSearching { LoadingFilesView().softCard(radius: 8).padding(.horizontal, 10) }
else if filteredFiles.isEmpty { EmptyFilesView(model: model).softCard(radius: 24).padding(.horizontal, 10) }
else {
List(selection: $selectedFileIDs) {
Section {
ForEach(filteredFiles) { file in
BrowserRow(file: file, isSelected: selectedFileIDs.contains(file.id), isLoadingDetails: model.detailLoadingIDs.contains(file.id), isActionLoading: model.actionLoadingIDs.contains(file.id)) { modifiers in
select(file, modifiers: modifiers)
} onOpen: {
selectedFileIDs = [file.id]
if file.isDirectory { Task { await model.openFolder(file) } } else { onPreview(file) }
}
.tag(file.id)
.onAppear { model.loadVisibleItemDetails(file) }
.contextMenu { fileMenu(file) }
.draggable(file.id)
.dropDestination(for: String.self) { values, _ in
guard file.isDirectory else { return false }
let ids = values.filter { $0 != file.id }
guard !ids.isEmpty else { return false }
Task { await model.move(fileIDs: ids, to: file.id) }
return true
}
}
} header: {
HStack {
sortHeader("名称", key: .name, alignment: .leading).frame(maxWidth: .infinity, alignment: .leading)
sortHeader("大小", key: .size, alignment: .trailing).frame(width: 96, alignment: .trailing)
sortHeader("修改时间", key: .modifiedAt, alignment: .trailing).frame(width: 132, alignment: .trailing)
Image(systemName: "chevron.right").opacity(0).frame(width: 18)
}
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.softCard(radius: 24)
.onDeleteCommand {
guard !selectedFileIDs.isEmpty else { return }
showBatchDeleteConfirmation = true
}
.onKeyPress(.delete, phases: .down) { press in
guard press.modifiers.contains(.command), !selectedFileIDs.isEmpty else { return .ignored }
showBatchDeleteConfirmation = true
return .handled
}
}
}
}
private func moveKeyboardSelection(direction: MoveCommandDirection) {
moveKeyboardSelection(page: direction == .up || direction == .left ? -1 : 1)
}
private func clearGlobalSearchResults() {
globalSearchText = ""
model.clearGlobalFileSearch()
selectedFileIDs.removeAll()
selectionAnchorID = nil
}
private func moveKeyboardSelection(page: Int) {
guard !filteredFiles.isEmpty else { return }
let step = abs(page) == 1 ? 1 : 12
let current = keyboardFocusID.flatMap { id in filteredFiles.firstIndex { $0.id == id } } ?? selectedFileIDs.first.flatMap { id in filteredFiles.firstIndex { $0.id == id } } ?? (page > 0 ? -1 : filteredFiles.count)
selectKeyboardIndex(max(0, min(filteredFiles.count - 1, current + (page > 0 ? step : -step))))
}
private func selectKeyboardIndex(_ index: Int) {
guard filteredFiles.indices.contains(index) else { return }
let file = filteredFiles[index]
keyboardFocusID = file.id; selectionAnchorID = file.id; selectedFileIDs = [file.id]
}
private func select(_ file: CloudFile, modifiers: NSEvent.ModifierFlags) {
keyboardFocusID = file.id
if modifiers.contains(.shift), let anchor = selectionAnchorID,
let start = filteredFiles.firstIndex(where: { $0.id == anchor }),
let end = filteredFiles.firstIndex(where: { $0.id == file.id }) {
selectedFileIDs.formUnion(filteredFiles[min(start, end)...max(start, end)].map(\.id))
} else if modifiers.contains(.command) {
if selectedFileIDs.contains(file.id) { selectedFileIDs.remove(file.id) }
else { selectedFileIDs.insert(file.id) }
selectionAnchorID = file.id
} else {
selectedFileIDs = [file.id]
selectionAnchorID = file.id
}
}
private func sortHeader(_ title: String, key: FileSort, alignment: Alignment) -> some View {
Button { Task { await model.setServerSort(key) } } label: {
HStack(spacing: 3) {
Text(title)
if model.serverSort == key { Image(systemName: model.serverSortDirection == .ascending ? "chevron.up" : "chevron.down").font(.system(size: 9, weight: .bold)) }
}
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: alignment)
.help("按\(title)进行服务端排序")
}
private var paginationControls: some View {
HStack(spacing: 10) {
Picker("每页", selection: Binding(get: { model.pageSize }, set: { size in Task { await model.setPageSize(size) } })) {
Text("50 / 页").tag(50); Text("100 / 页").tag(100); Text("200 / 页").tag(200); Text("500 / 页").tag(500); Text("1000 / 页").tag(1000); Text("5000 / 页").tag(5000); Text("10000 / 页").tag(10000)
}
.labelsHidden().frame(width: 108)
Button { Task { await model.goToPage(model.currentPage - 1) } } label: { Image(systemName: "chevron.left") }
.buttonStyle(.bordered).disabled(model.currentPage == 0 || model.isLoadingFiles)
Text("\(model.currentPage + 1) / \(model.totalPages)").font(.callout.monospacedDigit()).frame(minWidth: 62)
Button { Task { await model.goToPage(model.currentPage + 1) } } label: { Image(systemName: "chevron.right") }
.buttonStyle(.bordered).disabled(model.currentPage + 1 >= model.totalPages || model.isLoadingFiles)
}
}
private var moreMenu: some View {
Menu {
Button { model.reloadCurrentListSizes() } label: { Label("重新获取当前列表大小", systemImage: "arrow.triangle.2.circlepath") }
.disabled(model.isLoadingFolderSizes || model.files.isEmpty)
Divider()
Button { Task { await model.cleanEmptyFolders() } } label: { Label("清理空文件夹", systemImage: "sparkles") }
Button { Task { await model.scanDuplicates(); isShowingDuplicates = true } } label: { Label("扫描重复文件", systemImage: "square.on.square") }
Button { Task { await model.scanDuplicates(excludingSmallFiles: true); isShowingDuplicates = true } } label: { Label("扫描重复文件(排除小文件)", systemImage: "square.on.square.fill") }
Button { model.findSimilarFolders() } label: { Label("查询相似文件夹", systemImage: "rectangle.3.group") }
Divider()
Button { isShowingSettings = true } label: { Label("工作区设置", systemImage: "gearshape") }
} label: { Image(systemName: "ellipsis.circle.fill").font(.title3).foregroundStyle(.secondary) }
.menuStyle(.borderlessButton)
.help("更多工具")
}
@ViewBuilder private func fileMenu(_ file: CloudFile) -> some View {
Button { if file.isDirectory { Task { await model.openFolder(file) } } else { onPreview(file) } } label: { Label(file.isDirectory ? "打开文件夹" : "预览", systemImage: file.isDirectory ? "folder" : "eye") }
if !file.isDirectory { Button { Task { await model.download(file) } } label: { Label("下载", systemImage: "arrow.down.circle") } }
if file.isVideo {
Menu("播放") {
if AppModel.installedPlayers.isEmpty {
Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("系统默认播放器", systemImage: "play.fill") }
} else {
ForEach(AppModel.installedPlayers, id: \.name) { player in
Button { Task { await model.openFile(file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") }
}
}
}
} else if !file.isDirectory && !AppModel.installedPlayers.isEmpty {
Menu("用外部播放器打开") {
ForEach(AppModel.installedPlayers, id: \.name) { player in
Button { Task { await model.openFile(file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") }
}
}
}
Button {
selectedFileIDs = [file.id]
onShowInspector()
} label: { Label("在检查器中查看", systemImage: "sidebar.right") }
Button { Task { await model.share(file) } } label: { Label("分享", systemImage: "square.and.arrow.up") }
Menu("复制到") {
Button("当前文件夹") { Task { await model.copy(file, to: model.folderPath.last?.id) } }
Button("根目录") { Task { await model.copy(file, to: nil) } }
}
Menu("移动到") {
Button("当前文件夹") { Task { await model.move(file, to: model.folderPath.last?.id) } }
Button("根目录") { Task { await model.move(file, to: nil) } }
}
Divider()
Button { renameFile = file } label: { Label("重命名", systemImage: "pencil") }
if file.isDirectory {
Menu("文件夹工具") {
Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") }
Button { openScanTool(.emptyFolders, for: file) } label: { Label("扫描空文件夹", systemImage: "folder.badge.minus") }
Button { openScanTool(.duplicates, for: file) } label: { Label("扫描重复文件", systemImage: "square.on.square") }
Button { openScanTool(.similarFolders, for: file) } label: { Label("扫描相似文件夹", systemImage: "rectangle.3.group") }
}
} else {
Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") }
}
Button { model.copyTransferJSON(for: file) } label: { Label("复制秒传 JSON", systemImage: "doc.on.doc") }
Divider()
if model.section == .recycle { Button { Task { await model.restore(file) } } label: { Label("恢复", systemImage: "arrow.uturn.backward") } }
Button(role: .destructive) { requestDelete(file) } label: { Label("删除…", systemImage: "trash") }
}
private func requestDelete(_ file: CloudFile) {
// Right-clicking an already-selected row must preserve the whole selection.
// A non-selected row becomes the explicit one-item deletion target.
if !selectedFileIDs.contains(file.id) {
selectedFileIDs = [file.id]
selectionAnchorID = file.id
}
showBatchDeleteConfirmation = true
}
private func openScanTool(_ kind: ScanKind, for folder: CloudFile) {
model.startScan(ScanRequest(kind: kind, scope: .recursiveCurrentFolder, rootID: folder.id, rootName: folder.name, excludeFilesSmallerThan: nil))
onOpenTool(.scan)
}
private func openBatchRenameTool(for clickedFile: CloudFile) {
let selectedFiles = model.files.filter { selectedFileIDs.contains($0.id) }
if selectedFiles.count > 1 {
model.batchRenameSelection = selectedFiles
model.batchRenameTarget = nil
} else if selectedFileIDs.contains(clickedFile.id), let selectedFile = selectedFiles.first {
model.batchRenameSelection = [selectedFile]
model.batchRenameTarget = nil
} else if clickedFile.isDirectory {
model.batchRenameSelection = []
model.batchRenameTarget = clickedFile
} else {
model.batchRenameSelection = [clickedFile]
model.batchRenameTarget = nil
}
onOpenTool(.rename)
}
}
private struct BrowserRow: View {
let file: CloudFile
let isSelected: Bool
let isLoadingDetails: Bool
let isActionLoading: Bool
let onSelect: (NSEvent.ModifierFlags) -> Void
let onOpen: () -> Void
var body: some View {
Button(action: { onSelect(NSEvent.modifierFlags) }) {
HStack(spacing: 12) {
Image(systemName: file.icon)
.font(.title3.weight(.semibold))
.foregroundStyle(file.isDirectory ? .orange : .accentColor)
.frame(width: 34, height: 34)
.background((file.isDirectory ? Color.orange : Color.accentColor).opacity(0.11), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
VStack(alignment: .leading, spacing: 4) {
Text(file.name).font(.body.weight(.medium)).lineLimit(1)
HStack(spacing: 6) {
Text(file.typeName)
if isLoadingDetails { Text("·"); Text("正在获取详情") }
else if file.isDirectory, let folders = file.subDirectoryCount, let files = file.subFileCount { Text("·"); Text("\(folders) 个文件夹,\(files) 个文件") }
else if file.gcid != nil { Text("·"); Text("GCID 已获取") }
}.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Group {
if isActionLoading { ProgressView().controlSize(.small).help("正在处理…") }
else if isLoadingDetails && file.size == nil { ProgressView().controlSize(.small) }
else { Text(file.formattedSize).font(.callout.monospacedDigit()).foregroundStyle(.secondary) }
}.frame(width: 96, alignment: .trailing)
Text(file.modifiedAt.isEmpty ? "--" : file.modifiedAt).font(.callout.monospacedDigit()).foregroundStyle(.secondary).frame(width: 132, alignment: .trailing)
Image(systemName: file.isDirectory ? "chevron.right" : "info.circle").font(.caption.weight(.bold)).foregroundStyle(.tertiary).frame(width: 18)
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.orange.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(file.name)
.accessibilityValue(isSelected ? "已选中" : "未选中")
.accessibilityHint(file.isDirectory ? "双击打开文件夹" : "双击预览文件")
.accessibilityAddTraits(isSelected ? .isSelected : [])
.disabled(isActionLoading)
.opacity(isActionLoading ? 0.55 : 1)
.simultaneousGesture(TapGesture(count: 2).onEnded(onOpen))
}
}
private struct TMDBRecognizeSheet: View {
@ObservedObject var model: AppModel
let file: CloudFile
@Environment(\.dismiss) private var dismiss
@State private var tmdbID = ""
@State private var mediaKind: TMDBMediaKind = .automatic
@State private var chineseTitle = ""
@State private var englishTitle = ""
@State private var yearText = ""
@State private var hasQueried = false
private var parsed: ParsedMediaName { model.parsedTMDBInfo(for: file) }
private var queryJob: TMDBJob? { model.tmdbJobs.first(where: { $0.id == file.id }) }
private var queryTitle: String { chineseTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? englishTitle : chineseTitle }
var body: some View {
VStack(alignment: .leading, spacing: 18) {
SheetHeader(icon: "film.stack", title: "TMDB 识别与刮削", subtitle: file.name)
VStack(alignment: .leading, spacing: 8) {
Text("可编辑识别信息").font(.caption.weight(.bold)).foregroundStyle(.secondary)
HStack { TextField("中文标题", text: $chineseTitle).textFieldStyle(.roundedBorder); TextField("英文标题", text: $englishTitle).textFieldStyle(.roundedBorder); TextField("年份", text: $yearText).textFieldStyle(.roundedBorder).frame(width: 90) }
}
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
InspectorField(title: "自动解析标题", value: parsed.title)
InspectorField(title: "自动解析年份", value: parsed.year.map(String.init) ?? "--")
InspectorField(title: "集数", value: parsed.isEpisode ? "S\(String(format: "%02d", parsed.season ?? 0))E\(String(format: "%02d", parsed.episode ?? 0))" : "电影/整季")
InspectorField(title: "介质", value: parsed.isDiscStructure ? "ISO / BDMV / 光盘结构" : "普通媒体文件")
InspectorField(title: "分辨率", value: parsed.resolution ?? "--")
InspectorField(title: "来源 / 编码", value: [parsed.source, parsed.videoCodec].compactMap { $0 }.joined(separator: " · ").isEmpty ? "--" : [parsed.source, parsed.videoCodec].compactMap { $0 }.joined(separator: " · "))
InspectorField(title: "音频", value: parsed.audio ?? "--")
}
Picker("媒体类型", selection: $mediaKind) { ForEach(TMDBMediaKind.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented)
HStack { TextField("手动 TMDB ID", text: $tmdbID).textFieldStyle(.roundedBorder); Button("按 ID 建立刮削任务") { if let id = Int(tmdbID) { Task { await model.recognizeTMDBByID(file, tmdbID: id, mediaKind: mediaKind); dismiss() } } }.buttonStyle(.bordered) }
if hasQueried, let job = queryJob {
Divider()
Text("TMDB 候选结果").font(.headline)
if job.candidates.isEmpty { ContentUnavailableView("未找到可用结果", systemImage: "magnifyingglass", description: Text(job.note)) }
else { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(job.candidates) { candidate in Button { model.selectTMDBCandidate(jobID: job.id, candidateID: candidate.id) } label: { HStack(alignment: .top, spacing: 10) { Image(systemName: candidate.mediaType == .tv ? "tv" : "film").foregroundStyle(.purple).frame(width: 24); VStack(alignment: .leading, spacing: 3) { Text("\(candidate.title) \(candidate.releaseDate.prefix(4))").font(.callout.weight(.semibold)); if candidate.originalTitle != candidate.title { Text(candidate.originalTitle).font(.caption).foregroundStyle(.secondary) }; Text(candidate.overview.isEmpty ? "暂无简介" : candidate.overview).font(.caption).foregroundStyle(.secondary).lineLimit(2) }; Spacer(); if job.selectedCandidateID == candidate.id { Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) } } .padding(10).frame(maxWidth: .infinity, alignment: .leading).background(job.selectedCandidateID == candidate.id ? Color.purple.opacity(0.12) : Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) }.buttonStyle(.plain) } }.padding(.vertical, 2) }.frame(maxHeight: 240) }
if job.selectedCandidate != nil { Button { model.toggleTMDBApproval(jobID: job.id); dismiss() } label: { Label("使用已选候选建立任务", systemImage: "checkmark.circle") }.buttonStyle(.borderedProminent).tint(.purple) }
}
HStack { Spacer(); Button("取消") { dismiss() }; Button { Task { await model.queryTMDBManually(for: file, title: queryTitle, year: Int(yearText), mediaKind: mediaKind); hasQueried = true } } label: { if model.isRunningTMDBJob { ProgressView().controlSize(.small) }; Label(model.isRunningTMDBJob ? "正在查询…" : (hasQueried ? "重新查询" : "查询 TMDB 候选"), systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).tint(.purple).disabled(model.isRunningTMDBJob || queryTitle.isEmpty) }
}.padding(22).frame(minWidth: 620)
.onAppear { chineseTitle = parsed.title; englishTitle = ""; yearText = parsed.year.map(String.init) ?? "" }
}
}
private struct FileDetailsSheet: View {
@ObservedObject var model: AppModel
let file: CloudFile
@Environment(\.dismiss) private var dismiss
@State private var detail: JSONValue?
@State private var errorMessage = ""
@State private var isLoading = false
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 14) {
Image(systemName: file.icon).font(.title2.weight(.semibold)).foregroundStyle(file.isDirectory ? .orange : .blue)
.frame(width: 48, height: 48).background((file.isDirectory ? Color.orange : Color.blue).opacity(0.12), in: RoundedRectangle(cornerRadius: 15))
VStack(alignment: .leading, spacing: 3) { Text(file.name).font(.title3.weight(.bold)).lineLimit(2); Text("文件详情").font(.caption).foregroundStyle(.secondary) }
Spacer()
if isLoading { ProgressView().controlSize(.small) }
Button("完成") { dismiss() }.buttonStyle(.borderedProminent).tint(.orange)
}.padding(20)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
InspectorField(title: "文件 ID", value: file.id)
InspectorField(title: "类型", value: file.typeName)
InspectorField(title: "大小", value: file.formattedSize)
InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt)
}
if let detail {
Text("接口返回").font(.headline)
Text(jsonText(detail)).font(.system(.caption, design: .monospaced)).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading).padding(14)
.background(Color.black.opacity(0.04), in: RoundedRectangle(cornerRadius: 14))
} else if !errorMessage.isEmpty {
ContentUnavailableView("详情加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage))
} else if isLoading {
ProgressView("正在读取详情…").frame(maxWidth: .infinity, minHeight: 180)
}
}.padding(20)
}
}
.frame(minWidth: 620, minHeight: 520)
.task(id: file.id) { await loadDetails() }
}
private func loadDetails() async {
isLoading = true
errorMessage = ""
defer { isLoading = false }
do { detail = try await model.details(for: file) }
catch is CancellationError { }
catch { errorMessage = error.localizedDescription }
}
}
struct FilePreviewSheet: View {
@ObservedObject var model: AppModel
let file: CloudFile
var onClose: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var url: URL?
@State private var errorText = ""
private var ext: String { (file.name as NSString).pathExtension.lowercased() }
private var isImage: Bool { file.fileType == 1 || ["jpg", "jpeg", "png", "gif", "webp", "heic", "bmp", "tiff"].contains(ext) }
private var isVideo: Bool { file.isVideo }
var body: some View {
ZStack {
Color.black.opacity(0.92)
if let url {
if isImage {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image): image.resizable().scaledToFit().padding(24)
case .failure: previewFailure("图片加载失败", icon: "photo.badge.exclamationmark")
default: ProgressView("正在加载图片…").tint(.white).foregroundStyle(.white)
}
}
} else {
WebPreview(url: url)
}
} else if !errorText.isEmpty {
previewFailure(errorText, icon: "eye.slash")
} else {
ProgressView("正在获取预览地址…").tint(.white).foregroundStyle(.white)
}
}
.frame(minWidth: 900, minHeight: 650)
.navigationTitle(file.name)
.onExitCommand { closePreview() }
.task { await loadPreviewURL() }
}
private func loadPreviewURL() async {
do {
let fetchedURL = try await model.remoteURL(for: file)
url = fetchedURL
} catch {
errorText = error.localizedDescription
}
}
@ViewBuilder private func previewFailure(_ text: String, icon: String) -> some View {
ContentUnavailableView(text, systemImage: icon).foregroundStyle(.white)
}
private func closePreview() {
if let onClose { onClose() }
else { dismiss() }
}
}
/// Non-modal AppKit player window. Unlike a SwiftUI sheet, it never blocks the
/// cloud-drive window, so playback can stay open while files are managed.
@MainActor
final class PlayerWindowCoordinator: NSObject, NSWindowDelegate {
static let shared = PlayerWindowCoordinator()
private var windows: [String: NSWindow] = [:]
func open(model: AppModel, file: CloudFile) {
if file.isVideo {
Task { await openWithExternalPlayer(model: model, file: file) }
return
}
if let window = windows[file.id] {
window.makeKeyAndOrderFront(nil)
return
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1080, height: 720),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = file.name
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.isReleasedWhenClosed = false
window.minSize = NSSize(width: 760, height: 500)
window.delegate = self
window.standardWindowButton(.closeButton)?.isHidden = false
window.standardWindowButton(.miniaturizeButton)?.isHidden = false
window.standardWindowButton(.zoomButton)?.isHidden = false
window.contentViewController = NSHostingController(rootView: FilePreviewSheet(model: model, file: file, onClose: { [weak self] in
self?.close(fileID: file.id)
}))
windows[file.id] = window
window.center()
window.makeKeyAndOrderFront(nil)
}
private func openWithExternalPlayer(model: AppModel, file: CloudFile) async {
let installed = AppModel.installedPlayers
if installed.count == 1 {
await model.openFile(file, withPlayerNamed: installed[0].name)
} else if installed.count > 1 {
await MainActor.run {
showPlayerPicker(model: model, file: file, installed: installed)
}
} else {
await model.playWithExternalPlayer(file)
}
}
private func showPlayerPicker(model: AppModel, file: CloudFile, installed: [(name: String, bundleID: String)]) {
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 380, height: 0),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
panel.title = "选择播放器"
panel.isReleasedWhenClosed = false
panel.level = .floating
panel.isFloatingPanel = true
let hostingView = NSHostingView(rootView: PlayerPickerPanel(model: model, file: file, installed: installed, onDismiss: { panel.close() }))
panel.contentView = hostingView
panel.setContentSize(hostingView.fittingSize)
panel.center()
panel.makeKeyAndOrderFront(nil)
}
func close(fileID: String) {
guard let window = windows[fileID] else { return }
windows[fileID] = nil
window.close()
}
nonisolated func windowWillClose(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
Task { @MainActor [weak self] in
self?.windows = self?.windows.filter { $0.value !== window } ?? [:]
}
}
}
/// Floating panel for choosing among installed external players.
private struct PlayerPickerPanel: View {
@ObservedObject var model: AppModel
let file: CloudFile
let installed: [(name: String, bundleID: String)]
let onDismiss: () -> Void
private let columns = [
GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 0)
]
var body: some View {
VStack(spacing: 0) {
VStack(spacing: 6) {
Image(systemName: "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.orange)
Text("选择播放器")
.font(.title3.weight(.semibold))
Text(file.name)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.horizontal, 24)
.padding(.top, 24)
.padding(.bottom, 20)
Divider()
LazyVGrid(columns: columns, spacing: 4) {
ForEach(installed, id: \.name) { player in
PlayerCard(player: player, file: file, model: model, onDismiss: onDismiss)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
}
}
private struct PlayerCard: View {
let player: (name: String, bundleID: String)
let file: CloudFile
@ObservedObject var model: AppModel
let onDismiss: () -> Void
@State private var isHovering = false
@State private var iconImage: NSImage?
var body: some View {
Button {
Task { await model.openFile(file, withPlayerNamed: player.name) }
onDismiss()
} label: {
VStack(spacing: 6) {
Group {
if let img = iconImage {
Image(nsImage: img)
.resizable()
} else {
Image(systemName: "play.rectangle.fill")
.font(.system(size: 28))
.foregroundStyle(.orange)
}
}
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
Text(player.name)
.font(.caption2.weight(.medium))
.foregroundStyle(.primary)
.lineLimit(1)
}
.frame(width: 84, height: 76)
.background(isHovering ? Color(nsColor: .controlAccentColor).opacity(0.12) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(isHovering ? Color.accentColor.opacity(0.5) : Color(nsColor: .separatorColor), lineWidth: 0.5)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { isHovering = $0 }
.onAppear { loadIcon() }
}
private func loadIcon() {
guard iconImage == nil else { return }
guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: player.bundleID) else { return }
iconImage = NSWorkspace.shared.icon(forFile: url.path)
}
}
private struct WebPreview: NSViewRepresentable {
let url: URL
func makeNSView(context: Context) -> WKWebView { let view = WKWebView(); view.setValue(false, forKey: "drawsBackground"); return view }
func updateNSView(_ view: WKWebView, context: Context) { if view.url != url { view.load(URLRequest(url: url)) } }
}
struct FileInspector: View {
@ObservedObject var model: AppModel
let file: CloudFile?
let onPreview: (CloudFile) -> Void
let onClose: () -> Void
@State private var showsRawDetail = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let file {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .top) {
Image(systemName: file.icon)
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(file.isDirectory ? .orange : .accentColor)
.frame(width: 58, height: 58)
.background((file.isDirectory ? Color.orange : Color.accentColor).opacity(0.12), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
Spacer()
Button { Task { await model.showDetails(file) } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.bordered).help("刷新详情")
Button(action: onClose) { Image(systemName: "xmark") }.buttonStyle(.bordered).help("关闭详情检查器")
}
VStack(alignment: .leading, spacing: 7) {
Text(file.name).font(.title3.weight(.bold)).lineLimit(3)
Label(file.typeName, systemImage: file.isDirectory ? "folder" : "doc").font(.caption).foregroundStyle(.secondary)
}
HStack(spacing: 8) {
Button {
if file.isDirectory { Task { await model.openFolder(file) } }
else { onPreview(file) }
} label: {
Label(file.isDirectory ? "打开" : "预览", systemImage: file.isDirectory ? "folder" : "eye")
}
.buttonStyle(.borderedProminent).tint(.orange)
if !file.isDirectory {
Button { Task { await model.download(file) } } label: { Image(systemName: "arrow.down.circle") }
.buttonStyle(.bordered).help("下载")
}
Button { Task { await model.share(file) } } label: { Image(systemName: "square.and.arrow.up") }.buttonStyle(.bordered).help("分享")
}
Divider()
VStack(alignment: .leading, spacing: 12) {
InspectorField(title: "文件 ID", value: file.id)
InspectorField(title: "GCID", value: file.gcid ?? "未获取")
InspectorField(title: "大小", value: file.formattedSize)
if file.isDirectory {
InspectorField(title: "子文件夹", value: file.subDirectoryCount.map(String.init) ?? "--")
InspectorField(title: "子文件", value: file.subFileCount.map(String.init) ?? "--")
}
InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt)
}
if model.detailLoadingIDs.contains(file.id), model.detail == nil {
HStack(spacing: 8) { ProgressView().controlSize(.small); Text("正在读取服务器详情…").font(.caption).foregroundStyle(.secondary) }
} else if !model.detailErrorMessage.isEmpty {
Label(model.detailErrorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.caption).foregroundStyle(.red).fixedSize(horizontal: false, vertical: true)
} else if model.detailFileID == file.id, let detail = model.detail {
Divider()
DisclosureGroup("原始接口数据", isExpanded: $showsRawDetail) {
ScrollView {
Text(jsonText(detail)).font(.system(.caption, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 240)
.padding(.top, 8)
}
.font(.caption.weight(.semibold))
}
Spacer()
}.padding(22)
} else {
VStack(alignment: .leading, spacing: 18) {
HStack { Text("详情").font(.headline); Spacer(); Button(action: onClose) { Image(systemName: "xmark") }.buttonStyle(.bordered) }
ContentUnavailableView("未选择文件", systemImage: "doc.text.magnifyingglass", description: Text("选择一个文件后可在这里查看详细信息。"))
}.padding(18).frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(minWidth: 270, idealWidth: 310)
.background(.ultraThinMaterial)
.task(id: file?.id) {
showsRawDetail = false
guard let file else { model.detail = nil; model.detailFileID = nil; model.detailErrorMessage = ""; return }
await model.showDetails(file)
}
}
}
private struct LoadingFilesView: View {
var body: some View {
VStack(spacing: 14) {
ProgressView().controlSize(.large)
Text("正在加载文件夹内容…").font(.callout.weight(.medium))
Text("正在同步文件、大小和修改时间").font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .combine)
.accessibilityLabel("正在加载文件夹内容")
}
}
private struct InspectorField: View { let title: String; let value: String; var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title.uppercased()).font(.system(size: 9, weight: .bold)).foregroundStyle(.tertiary); Text(value).font(.caption).lineLimit(3).textSelection(.enabled) }.frame(maxWidth: .infinity, alignment: .leading).padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } }
struct ToolWorkspacePage: View {
@ObservedObject var model: AppModel
let tool: WorkspaceTool
var body: some View {
if tool == .media {
MediaLibraryPage(model: model)
} else if tool == .fastTransfer {
FastTransferToolPage(model: model)
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 14) {
Image(systemName: tool.icon).font(.title2.weight(.semibold)).foregroundStyle(.orange).frame(width: 52, height: 52).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 17, style: .continuous))
VStack(alignment: .leading, spacing: 4) { Text(tool.rawValue).font(.system(size: 28, weight: .bold, design: .rounded)); Text(tool.subtitle).foregroundStyle(.secondary) }
Spacer()
}
.padding(20).softCard(radius: 24)
switch tool {
case .media: EmptyView()
case .scan: UnifiedScanToolPage(model: model)
case .rename: BatchRenameToolPage(model: model)
case .fastTransfer: EmptyView()
case .tmdb: EmptyView()
case .settings: ToolSettingsPage(model: model)
}
}
.padding(14)
.background(Color.clear)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
}
private enum FastTransferToolMode: String, CaseIterable, Identifiable {
case importJSON
case generateJSON
var id: String { rawValue }
var title: String { self == .importJSON ? "JSON 秒传" : "本地生成 JSON" }
}
private enum FastTransferImportPhase {
case chooseSource
case parsing
case ready
}
private enum FastTransferQueueSection: String, CaseIterable, Identifiable {
case pending
case issues
case imported
case skipped
var id: String { rawValue }
var title: String {
switch self {
case .pending: return "主队列"
case .issues: return "出错"
case .imported: return "成功"
case .skipped: return "已跳过"
}
}
}
private struct FastTransferQueueSnapshot {
typealias Task = (entry: FastTransferEntry, result: FastTransferResult?)
var latestResults: [UUID: FastTransferResult] = [:]
var retriedResultIDs: Set<UUID> = []
var entryByID: [UUID: FastTransferEntry] = [:]
var pending: [Task] = []
var issues: [Task] = []
var imported: [Task] = []
var skipped: [Task] = []
init(entries: [FastTransferEntry] = [], results: [FastTransferResult] = []) {
latestResults = results.reduce(into: [:]) { $0[$1.entry.id] = $1 }
retriedResultIDs = Set(results.compactMap(\.retryOf))
entryByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) })
pending.reserveCapacity(entries.count)
for entry in entries {
let task = (entry: entry, result: latestResults[entry.id])
guard let result = task.result else {
pending.append(task)
continue
}
switch result.state {
case .imported: imported.append(task)
case .skipped: skipped.append(task)
case .failed, .cancelled: issues.append(task)
}
}
}
func tasks(in section: FastTransferQueueSection) -> [Task] {
switch section {
case .pending: return pending
case .issues: return issues
case .imported: return imported
case .skipped: return skipped
}
}
}
private struct FastTransferToolPage: View {
@ObservedObject var model: AppModel
@State private var mode: FastTransferToolMode = .importJSON
@State private var jsonText = ""
@State private var importPhase: FastTransferImportPhase = .chooseSource
@State private var localURLs: [URL] = []
@State private var generatedEntries: [FastTransferEntry] = []
@State private var generatedJSON = ""
@State private var targetID: String?
@State private var targetName = "当前文件夹"
@AppStorage("guangya.fastTransfer.createDirectories") private var createDirectories = true
@AppStorage("guangya.fastTransfer.skipExisting") private var skipExisting = true
@State private var showTargetPicker = false
@State private var showLocalImporter = false
@State private var localError = ""
@State private var importTask: Task<Void, Never>?
@State private var queuePage = 0
@State private var queuePageSize = 200
@State private var queueSection: FastTransferQueueSection = .pending
@State private var queueSnapshot = FastTransferQueueSnapshot()
var body: some View {
Group {
if mode == .importJSON && importPhase != .ready {
importSourceView
} else {
VStack(spacing: 0) {
Picker("功能", selection: $mode) {
ForEach(FastTransferToolMode.allCases) { option in
Text(option.title).tag(option)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 22).padding(.vertical, 16)
Divider()
Group {
switch mode {
case .importJSON: importView
case .generateJSON: generateView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.sheet(isPresented: $showTargetPicker) {
ScanFolderPickerSheet(model: model, title: "选择秒传目标目录", selectionLabel: "使用此目录") { id, path in
saveTargetDirectory(id: id, path: path)
}
}
.fileImporter(isPresented: $showLocalImporter, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
guard case .success(let urls) = result else { return }
localURLs = urls
generateJSON(for: urls)
}
.alert("秒传工具", isPresented: Binding(get: { !localError.isEmpty }, set: { if !$0 { localError = "" } })) {
Button("确定", role: .cancel) { localError = "" }
} message: {
Text(localError)
}
.onAppear {
refreshQueueSnapshot()
if model.isFastTransferRunning || model.hasPendingFastTransferEntries {
mode = .importJSON
importPhase = .ready
}
let defaults = UserDefaults.standard
if defaults.bool(forKey: "guangya.fastTransfer.hasSavedTarget") {
let savedID = defaults.string(forKey: "guangya.fastTransfer.targetID") ?? ""
targetID = savedID.isEmpty ? nil : savedID
targetName = defaults.string(forKey: "guangya.fastTransfer.targetName") ?? "云盘根目录"
} else {
targetID = model.folderPath.last?.id
targetName = model.folderPath.isEmpty ? "云盘根目录" : model.folderPath.map(\.name).joined(separator: " / ")
}
}
// Published emits before its stored value changes. Build from the value
// delivered by the publisher rather than reading the old model array,
// otherwise a freshly parsed JSON can momentarily cache an empty queue.
.onReceive(model.$fastTransferEntries) { entries in
refreshQueueSnapshot(entries: entries, results: model.fastTransferResults)
}
.onReceive(model.$fastTransferResults) { results in
refreshQueueSnapshot(entries: model.fastTransferEntries, results: results)
}
}
private var importSourceView: some View {
VStack(spacing: 18) {
if importPhase == .parsing {
ProgressView().controlSize(.large)
Text("正在分析 JSON").font(.title3.weight(.semibold))
Text("正在读取秒传任务和校验信息").font(.callout).foregroundStyle(.secondary)
} else {
Image(systemName: "bolt.badge.clock")
.font(.system(size: 42, weight: .light)).foregroundStyle(.orange)
.frame(width: 88, height: 88)
.background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
VStack(spacing: 6) {
Text("导入秒传任务").font(.title2.weight(.bold))
Text("选择 JSON 来源后会先解析为可检查的任务列表。")
.font(.callout).foregroundStyle(.secondary)
}
HStack(spacing: 14) {
fastTransferSourceButton("粘贴 JSON", icon: "doc.on.clipboard", action: pasteJSONFromClipboard)
fastTransferSourceButton("选择 JSON", icon: "folder.badge.plus", action: chooseJSONFile)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(30)
}
private func fastTransferSourceButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 12) {
Image(systemName: icon).font(.title2.weight(.semibold))
Text(title).font(.headline)
}
.frame(width: 190, height: 132)
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 8))
.overlay { RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.14), lineWidth: 1) }
}
.buttonStyle(.plain)
.disabled(importPhase == .parsing)
}
private var importView: some View {
VStack(spacing: 0) {
transferCommandBar
Divider()
taskList
.padding(18)
}
.onChange(of: model.fastTransferProgress.currentName) { _, path in
let pending = queueSnapshot.pending
guard queueSection == .pending,
model.isFastTransferRunning,
let index = pending.firstIndex(where: { $0.entry.path == path }) else { return }
queuePage = index / queuePageSize
}
}
private var transferCommandBar: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text("秒传任务").font(.headline.weight(.semibold))
HStack(spacing: 5) {
Image(systemName: "folder")
Text(targetName).lineLimit(1)
}
.font(.caption)
.foregroundStyle(.secondary)
}
Button { showTargetPicker = true } label: { Image(systemName: "folder.badge.gearshape") }
.buttonStyle(.bordered)
.help("选择目标目录")
.disabled(model.isFastTransferRunning)
Divider().frame(height: 28)
Toggle("创建目录", isOn: $createDirectories)
.toggleStyle(.checkbox)
.font(.caption)
.disabled(model.isFastTransferRunning)
Toggle("跳过同名", isOn: $skipExisting)
.toggleStyle(.checkbox)
.font(.caption)
.disabled(model.isFastTransferRunning)
Stepper(value: $model.fastTransferConcurrency, in: 1...20) {
HStack(spacing: 4) {
Image(systemName: "point.3.connected.trianglepath.dotted")
Text("并发 \(model.fastTransferConcurrency)")
.monospacedDigit()
}
.font(.caption)
}
.frame(width: 94)
.disabled(model.isFastTransferRunning)
.help("同时提交的秒传请求数量")
Spacer(minLength: 10)
Button { resetImportSource() } label: { Image(systemName: "arrow.uturn.backward") }
.buttonStyle(.bordered)
.help("重新选择 JSON")
.disabled(model.isFastTransferRunning)
if model.isFastTransferRunning {
Button {
if model.isFastTransferPaused { model.resumeFastTransfer() }
else { model.pauseFastTransfer() }
} label: {
Label(model.isFastTransferPaused ? "继续" : "暂停", systemImage: model.isFastTransferPaused ? "play.fill" : "pause.fill")
}
.buttonStyle(.bordered)
Button(role: .destructive, action: stopImport) { Label("终止", systemImage: "stop.fill") }
.buttonStyle(.bordered)
.help("终止当前任务,保留未执行任务")
} else {
Button { runImport() } label: {
Label("开始秒传 \(pendingEntries.count) 项", systemImage: "bolt.fill")
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.disabled(pendingEntries.isEmpty)
}
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
private var generateView: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("本地文件生成秒传 JSON").font(.headline.weight(.semibold))
Text(localURLs.isEmpty ? "" : "已选择 \(localURLs.count) 个文件或文件夹")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { showLocalImporter = true } label: { Label("选择文件或文件夹", systemImage: "folder.badge.plus") }
.buttonStyle(.bordered)
Button { saveGeneratedJSON() } label: { Label("导出 JSON", systemImage: "square.and.arrow.up") }
.buttonStyle(.borderedProminent).disabled(generatedJSON.isEmpty || model.isGeneratingFastTransferJSON)
}
if model.isGeneratingFastTransferJSON || model.fastTransferGenerationProgress.total > 0 {
progressView(model.fastTransferGenerationProgress, title: "GCID 计算进度")
}
if generatedJSON.isEmpty && !model.isGeneratingFastTransferJSON {
ContentUnavailableView("尚未选择本地文件", systemImage: "doc.badge.plus")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
TextEditor(text: .constant(generatedJSON))
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(8)
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 7))
.overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(0.12), lineWidth: 1) }
.textSelection(.enabled)
}
}
.padding(22)
}
private var taskList: some View {
let pendingTasks = queueSnapshot.pending
let issueTasks = queueSnapshot.issues
let importedTasks = queueSnapshot.imported
let skippedTasks = queueSnapshot.skipped
let tasks = queueSnapshot.tasks(in: queueSection)
let retryable = retryableResults
let retryableIDs = Set(retryable.map(\.id))
let pageCount = max(1, Int(ceil(Double(tasks.count) / Double(queuePageSize))))
let currentPage = min(queuePage, pageCount - 1)
let pageStart = min(currentPage * queuePageSize, tasks.count)
let visibleTasks = Array(tasks.dropFirst(pageStart).prefix(queuePageSize))
let pageEnd = pageStart + visibleTasks.count
return VStack(alignment: .leading, spacing: 7) {
HStack {
Label(queueSection.title, systemImage: queueSection == .pending ? "list.bullet.rectangle" : (queueSection == .issues ? "exclamationmark.triangle" : (queueSection == .imported ? "checkmark.circle" : "forward.circle")))
.font(.headline.weight(.semibold))
Text("\(tasks.count) 项")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if !model.fastTransferEntries.isEmpty {
Button("清空任务") { model.clearFastTransferSession() }
.buttonStyle(.borderless)
.controlSize(.small)
.disabled(model.isFastTransferRunning)
}
}
HStack(spacing: 5) {
queueSectionTab(.pending, count: pendingTasks.count, icon: "list.bullet", tint: .orange)
queueSectionTab(.issues, count: issueTasks.count, icon: "exclamationmark.triangle", tint: .red)
queueSectionTab(.imported, count: importedTasks.count, icon: "checkmark.circle", tint: .green)
queueSectionTab(.skipped, count: skippedTasks.count, icon: "forward.circle", tint: .secondary)
}
.padding(3)
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
HStack(spacing: 8) {
Text(tasks.isEmpty ? "暂无任务" : "显示 \(pageStart + 1)-\(pageEnd) / \(tasks.count)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.tertiary)
Spacer()
Picker("每页", selection: $queuePageSize) {
Text("200 条/页").tag(200)
Text("500 条/页").tag(500)
Text("1,000 条/页").tag(1_000)
}
.labelsHidden()
.pickerStyle(.menu)
.controlSize(.small)
.onChange(of: queuePageSize) { _, _ in queuePage = 0 }
Button { queuePage = max(0, currentPage - 1) } label: { Image(systemName: "chevron.left") }
.buttonStyle(.borderless)
.disabled(currentPage == 0)
.help("上一页")
Text("\(currentPage + 1)/\(pageCount)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(minWidth: 38)
Button { queuePage = min(pageCount - 1, currentPage + 1) } label: { Image(systemName: "chevron.right") }
.buttonStyle(.borderless)
.disabled(currentPage >= pageCount - 1)
.help("下一页")
}
if model.isFastTransferRunning || model.fastTransferProgress.total > 0 {
queueProgress
}
if queueSection == .issues && !retryable.isEmpty {
HStack(spacing: 8) {
Label("失败任务可单独重传", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("重传失败项") { retry(retryable) }
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(model.isFastTransferRunning)
}
}
List {
if visibleTasks.isEmpty {
ContentUnavailableView(emptyQueueTitle, systemImage: queueSection == .pending ? "checkmark.circle" : "tray")
} else {
ForEach(visibleTasks, id: \.entry.id) { task in
let entry = task.entry
let isActive = isTaskActive(entry)
HStack(alignment: .top, spacing: 9) {
Group {
if isActive {
if model.isFastTransferPaused {
Image(systemName: "pause.circle.fill").foregroundStyle(.orange)
} else {
ProgressView().controlSize(.small)
}
} else {
Image(systemName: isTaskQueuedForCancellation(entry) ? "xmark.circle" : taskIcon(task.result))
.foregroundStyle(isTaskQueuedForCancellation(entry) ? .secondary : taskColor(task.result))
}
}
.frame(width: 18, height: 18)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(entry.name).font(.callout.weight(.medium)).lineLimit(1)
Text(taskStatusTitle(for: entry, result: task.result, isActive: isActive))
.font(.caption2.weight(.semibold))
.foregroundStyle(taskColor(task.result, isActive: isActive))
}
Text(entry.path).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
Text(taskMetadata(entry))
.font(.caption2.monospaced()).foregroundStyle(.tertiary).lineLimit(1)
if let result = task.result { Text(result.message).font(.caption2).foregroundStyle(taskColor(result)).lineLimit(1) }
}
Spacer(minLength: 4)
if let result = task.result,
result.state == .failed,
retryableIDs.contains(result.id) {
Button { retry([result]) } label: { Image(systemName: "arrow.clockwise") }
.buttonStyle(.borderless)
.help("重传此任务")
.disabled(model.isFastTransferRunning)
}
if isActive {
Button(role: .destructive, action: stopImport) { Image(systemName: "stop.fill") }
.buttonStyle(.borderless)
.help("终止当前传输,保留未执行任务")
} else if task.result == nil {
Button { cancelTask(entry) } label: { Image(systemName: "xmark") }
.buttonStyle(.borderless)
.help("取消任务")
.disabled(isTaskQueuedForCancellation(entry))
}
}
.contextMenu {
Button { copyTaskPath(entry.path) } label: { Label("复制路径", systemImage: "doc.on.doc") }
if task.result == nil {
Button(role: .destructive) { cancelTask(entry) } label: { Label("取消任务", systemImage: "xmark.circle") }
.disabled(isTaskQueuedForCancellation(entry))
}
}
}
}
}
.listStyle(.plain)
.frame(minHeight: 145, maxHeight: .infinity)
}
}
private func queueSectionTab(_ section: FastTransferQueueSection, count: Int, icon: String, tint: Color) -> some View {
let isSelected = queueSection == section
return Button {
queueSection = section
queuePage = 0
} label: {
HStack(spacing: 5) {
Image(systemName: icon)
Text(section.title)
Text("\(count)").monospacedDigit()
}
.font(.caption.weight(isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? tint : .secondary)
.padding(.horizontal, 10)
.frame(height: 28)
.frame(maxWidth: .infinity)
.background(isSelected ? tint.opacity(0.14) : .clear, in: RoundedRectangle(cornerRadius: 5, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("\(section.title)\(count) 项")
}
private var emptyQueueTitle: String {
switch queueSection {
case .pending: return "主队列已处理完毕"
case .issues: return "没有出错任务"
case .imported: return "还没有成功的任务"
case .skipped: return "没有跳过的任务"
}
}
private var pendingEntries: [FastTransferEntry] {
queueSnapshot.pending.map(\.entry)
}
private var latestResultsByEntryID: [UUID: FastTransferResult] {
queueSnapshot.latestResults
}
private func taskIcon(_ result: FastTransferResult?) -> String {
guard let result else { return "clock" }
return resultIcon(result.state)
}
private func taskColor(_ result: FastTransferResult?) -> Color {
guard let result else { return .orange }
return resultColor(result.state)
}
private func taskColor(_ result: FastTransferResult?, isActive: Bool) -> Color {
isActive ? (model.isFastTransferPaused ? .orange : .accentColor) : taskColor(result)
}
private func isTaskActive(_ entry: FastTransferEntry) -> Bool {
model.isFastTransferRunning && model.fastTransferProgress.activeEntryIDs.contains(entry.id)
}
private func taskStatusTitle(for entry: FastTransferEntry, result: FastTransferResult?, isActive: Bool) -> String {
if isActive { return model.isFastTransferPaused ? "已暂停" : "处理中" }
if result == nil, isTaskQueuedForCancellation(entry) { return "待取消" }
guard let result else { return "待处理" }
return resultTitle(result.state)
}
private func isTaskQueuedForCancellation(_ entry: FastTransferEntry) -> Bool {
model.cancelledFastTransferEntryIDs.contains(entry.id)
}
private var importedTaskCount: Int {
model.fastTransferProgress.imported
}
private var skippedTaskCount: Int {
model.fastTransferProgress.skipped
}
private var failedTaskCount: Int {
model.fastTransferProgress.failed
}
private var cancelledTaskCount: Int {
model.fastTransferProgress.cancelled
}
private var queueProgress: some View {
let progress = model.fastTransferProgress
let activeEntries = model.fastTransferActiveEntries
return VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
Label(model.isFastTransferPaused ? "任务已暂停" : "任务进度", systemImage: model.isFastTransferPaused ? "pause.circle" : "chart.bar.xaxis")
.font(.caption.weight(.semibold))
Spacer()
Text("\(progress.processed)/\(progress.total)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
TimelineView(.periodic(from: .now, by: 1)) { context in
if model.isFastTransferPaused {
Text("已暂停")
} else if let remaining = model.fastTransferEstimatedRemaining(at: context.date) {
Text("预计剩余 \(fastTransferDurationText(remaining))")
} else if model.isFastTransferRunning, progress.processed == 0 {
Text("正在估算")
}
}
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
ProgressView(value: progress.fraction)
.tint(.orange)
HStack(spacing: 12) {
Label("成功 \(importedTaskCount)", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Label("已跳过 \(skippedTaskCount)", systemImage: "forward.fill")
.foregroundStyle(.secondary)
if failedTaskCount > 0 {
Label("失败 \(failedTaskCount)", systemImage: "xmark.circle.fill")
.foregroundStyle(.red)
}
if cancelledTaskCount > 0 {
Label("已终止 \(cancelledTaskCount)", systemImage: "stop.circle.fill")
.foregroundStyle(.secondary)
}
}
.font(.caption2)
if !activeEntries.isEmpty {
Divider()
HStack(spacing: 6) {
Text(model.isFastTransferPaused ? "已暂停" : "正在上传")
.font(.caption2.weight(.semibold))
Text("\(activeEntries.count)/\(model.fastTransferConcurrency)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
ForEach(activeEntries) { entry in
HStack(spacing: 7) {
if model.isFastTransferPaused {
Image(systemName: "pause.circle.fill").foregroundStyle(.orange)
} else {
ProgressView().controlSize(.small)
}
Text(entry.name).lineLimit(1)
Spacer(minLength: 4)
Text(byteCount(entry.size)).foregroundStyle(.tertiary)
}
.font(.caption2)
}
} else if !progress.currentName.isEmpty {
Text(progress.currentName)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(10)
.background(Color.orange.opacity(0.07), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
.accessibilityElement(children: .combine)
.accessibilityLabel("秒传任务进度,已处理 \(progress.processed) 项,共 \(progress.total) 项")
}
private func fastTransferDurationText(_ interval: TimeInterval) -> String {
let total = max(0, Int(interval.rounded(.up)))
if total >= 3_600 { return "\(total / 3_600)小时\((total % 3_600) / 60)分" }
if total >= 60 { return "\(total / 60)\(total % 60)秒" }
return "\(total)秒"
}
private var retryableResults: [FastTransferResult] {
queueSnapshot.issues.compactMap(\.result).filter { $0.state == .failed && !queueSnapshot.retriedResultIDs.contains($0.id) }
}
private func resultTitle(_ state: FastTransferResult.State) -> String {
switch state {
case .imported: return "已完成"
case .skipped: return "已跳过"
case .failed: return "失败"
case .cancelled: return "已终止"
}
}
private func byteCount(_ value: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: value)
}
private func taskMetadata(_ entry: FastTransferEntry) -> String {
let hashValue = entry.etag ?? entry.gcid ?? "-"
return "\(byteCount(entry.size)) · \(entry.hashKind) \(hashValue)"
}
private func progressView(_ progress: FastTransferProgress, title: String) -> some View {
VStack(alignment: .leading, spacing: 7) {
HStack {
Text(title).font(.caption.weight(.semibold))
Spacer()
Text("\(progress.processed)/\(progress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
ProgressView(value: progress.fraction)
if !progress.currentName.isEmpty { Text(progress.currentName).font(.caption2).foregroundStyle(.secondary).lineLimit(1) }
}
.padding(10)
.background(Color.orange.opacity(0.07), in: RoundedRectangle(cornerRadius: 7))
}
private func validateJSON() {
analyzeJSON(jsonText)
}
private func analyzeJSON(_ text: String) {
let source = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !source.isEmpty else {
localError = "JSON 内容为空"
return
}
jsonText = text
importPhase = .parsing
Task {
await Task.yield()
do {
try await Task.sleep(for: .milliseconds(120))
let entries = try model.parseFastTransferJSON(text)
model.replaceFastTransferEntries(entries)
// State publishers are delivered on a later render pass. Refresh
// synchronously so a freshly parsed JSON never opens an empty
// task panel while SwiftUI waits for that notification.
refreshQueueSnapshot()
queueSection = .pending
queuePage = 0
importPhase = .ready
} catch {
model.clearFastTransferSession()
importPhase = .chooseSource
localError = error.localizedDescription
}
}
}
private func pasteJSONFromClipboard() {
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else {
localError = "剪贴板中没有可用的 JSON 文本"
return
}
analyzeJSON(text)
}
private func removeTask(_ entry: FastTransferEntry) {
model.fastTransferEntries.removeAll { $0.id == entry.id }
}
private func cancelTask(_ entry: FastTransferEntry) {
if model.isFastTransferRunning {
model.cancelFastTransferEntry(entry.id)
} else {
removeTask(entry)
}
}
private func saveTargetDirectory(id: String?, path: String) {
targetID = id
targetName = path
let defaults = UserDefaults.standard
defaults.set(true, forKey: "guangya.fastTransfer.hasSavedTarget")
defaults.set(id ?? "", forKey: "guangya.fastTransfer.targetID")
defaults.set(path, forKey: "guangya.fastTransfer.targetName")
}
private func copyTaskPath(_ path: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(path, forType: .string)
}
private func chooseJSONFile() {
let panel = NSOpenPanel()
panel.title = "选择秒传 JSON"
panel.prompt = "打开"
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
guard panel.runModal() == .OK, let url = panel.url else { return }
let accessed = url.startAccessingSecurityScopedResource()
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
do {
analyzeJSON(try String(contentsOf: url, encoding: .utf8))
} catch {
localError = error.localizedDescription
}
}
private func resetImportSource() {
guard !model.isFastTransferRunning else { return }
jsonText = ""
model.clearFastTransferSession()
importPhase = .chooseSource
}
private func refreshQueueSnapshot(entries: [FastTransferEntry]? = nil, results: [FastTransferResult]? = nil) {
queueSnapshot = FastTransferQueueSnapshot(entries: entries ?? model.fastTransferEntries, results: results ?? model.fastTransferResults)
let count = queueSnapshot.tasks(in: queueSection).count
let maxPage = max(0, Int(ceil(Double(count) / Double(queuePageSize))) - 1)
if queuePage > maxPage { queuePage = maxPage }
}
private func runImport() {
guard !pendingEntries.isEmpty else { return }
startImport(pendingEntries)
}
private func retry(_ results: [FastTransferResult]) {
guard !results.isEmpty else { return }
let retryingResultIDs = Dictionary(uniqueKeysWithValues: results.map { ($0.entry.id, $0.id) })
startImport(results.map(\.entry), retryingResultIDs: retryingResultIDs)
}
private func startImport(_ entries: [FastTransferEntry], retryingResultIDs: [UUID: UUID] = [:]) {
importTask = Task {
_ = await model.importFastTransferEntries(
entries,
parentID: targetID,
createDirectories: createDirectories,
skipExisting: skipExisting,
retryingResultIDs: retryingResultIDs
)
importTask = nil
}
}
private func stopImport() {
model.cancelFastTransfer()
importTask?.cancel()
}
private func generateJSON(for urls: [URL]) {
Task {
let accessed = urls.map { ($0, $0.startAccessingSecurityScopedResource()) }
defer { accessed.forEach { if $0.1 { $0.0.stopAccessingSecurityScopedResource() } } }
do {
generatedEntries = try await model.generateFastTransferEntries(from: urls)
generatedJSON = try model.fastTransferJSON(for: generatedEntries)
if model.fastTransferGenerationProgress.failed > 0 {
localError = "已生成 \(generatedEntries.count) 项,\(model.fastTransferGenerationProgress.failed) 项无法读取。"
}
} catch {
localError = error.localizedDescription
}
}
}
private func saveGeneratedJSON() {
let panel = NSSavePanel()
panel.nameFieldStringValue = "光鸭秒传.json"
panel.allowedContentTypes = [.json]
guard panel.runModal() == .OK, let url = panel.url else { return }
do { try generatedJSON.write(to: url, atomically: true, encoding: .utf8) }
catch { localError = error.localizedDescription }
}
private func resultIcon(_ state: FastTransferResult.State) -> String {
switch state {
case .imported: return "checkmark.circle.fill"
case .skipped: return "forward.circle.fill"
case .failed: return "xmark.circle.fill"
case .cancelled: return "stop.circle.fill"
}
}
private func resultColor(_ state: FastTransferResult.State) -> Color {
switch state {
case .imported: return .green
case .skipped: return .orange
case .failed: return .red
case .cancelled: return .secondary
}
}
}
private struct UnifiedScanToolPage: View {
@ObservedObject var model: AppModel
@State private var selectedRootID: String?
@State private var selectedRootName = "整个云盘"
@State private var showFolderPicker = false
@State private var duplicateMinimumSizeMB = 0
var body: some View {
VStack(spacing: 16) {
ToolPageCard(icon: "magnifyingglass.circle.fill", tint: .orange, title: "统一扫描", detail: "选择扫描范围后,可分别扫描空文件夹、重复文件及相似文件夹;扫描不会修改云端文件。") {
HStack { VStack(alignment: .leading, spacing: 2) { Text("扫描范围").font(.caption).foregroundStyle(.secondary); Text(selectedRootName).font(.callout.weight(.semibold)).lineLimit(1) }; Spacer(); Button("浏览云盘…") { showFolderPicker = true }.buttonStyle(.bordered); Button("全盘扫描") { selectedRootID = nil; selectedRootName = "整个云盘" }.buttonStyle(.borderedProminent).tint(.orange) }
HStack(spacing: 10) { scanButton(.emptyFolders, "扫描空文件夹", "folder.badge.minus"); scanButton(.duplicates, "扫描重复文件", "square.on.square"); scanButton(.similarFolders, "扫描相似文件夹", "rectangle.3.group") }
HStack(spacing: 10) {
Label("重复文件大小筛选", systemImage: "line.3.horizontal.decrease.circle")
.font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Stepper(value: $duplicateMinimumSizeMB, in: 0...50_000, step: 10) {
Text(duplicateMinimumSizeMB == 0 ? "不跳过小文件" : "跳过小于 \(duplicateMinimumSizeMB) MB 的文件")
.font(.caption).foregroundStyle(.secondary)
}
.disabled(model.isScanning)
Spacer()
}
}
if model.activeScanRequest != nil || model.scanResult != nil {
ScanToolPage(model: model, kind: model.activeScanRequest?.kind ?? model.scanResult!.request.kind)
.id(model.activeScanRequest?.kind ?? model.scanResult!.request.kind)
.frame(maxHeight: .infinity, alignment: .top)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.sheet(isPresented: $showFolderPicker) {
ScanFolderPickerSheet(model: model) { id, name in
selectedRootID = id
selectedRootName = name
}
}
.onAppear { syncSelectedRoot() }
.onChange(of: model.activeScanRequest) { _, _ in syncSelectedRoot() }
}
private func syncSelectedRoot() {
guard let request = model.activeScanRequest else { return }
selectedRootID = request.rootID
selectedRootName = request.rootName
}
@ViewBuilder private func scanButton(_ kind: ScanKind, _ title: String, _ icon: String) -> some View {
let isActive = model.isScanning && model.activeScanRequest?.kind == kind
if isActive {
scanActionButton(kind, "正在扫描\(kind.title)", icon, showsProgress: true)
.buttonStyle(.borderedProminent)
.tint(scanTint(for: kind))
} else {
scanActionButton(kind, title, icon, showsProgress: false)
.buttonStyle(.bordered)
}
}
private func scanActionButton(_ kind: ScanKind, _ title: String, _ icon: String, showsProgress: Bool) -> some View {
let minimumSize = kind == .duplicates && duplicateMinimumSizeMB > 0
? Int64(duplicateMinimumSizeMB) * 1_024 * 1_024
: nil
return Button { model.startScan(ScanRequest(kind: kind, scope: selectedRootID == nil ? .wholeDrive : .recursiveCurrentFolder, rootID: selectedRootID, rootName: selectedRootName, excludeFilesSmallerThan: minimumSize)) } label: {
HStack(spacing: 6) {
if showsProgress { ProgressView().controlSize(.small) }
Label(title, systemImage: icon)
}
}
.disabled(model.isScanning)
}
private func scanTint(for kind: ScanKind) -> Color {
switch kind {
case .emptyFolders: return .orange
case .duplicates: return .blue
case .similarFolders: return .green
}
}
}
private enum ScanFolderPickerSort: String, CaseIterable, Identifiable {
case nameAscending
case nameDescending
case lengthAscending
case lengthDescending
var id: String { rawValue }
var title: String {
switch self {
case .nameAscending: return "名称 A-Z"
case .nameDescending: return "名称 Z-A"
case .lengthAscending: return "名称长度:短到长"
case .lengthDescending: return "名称长度:长到短"
}
}
}
struct ScanFolderPickerSheet: View {
@ObservedObject var model: AppModel
var title = "选择扫描文件夹"
var selectionLabel = "选择此文件夹"
var allowsFilteringAndSorting = false
let onSelect: (String?, String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var path: [FolderPath] = []
@State private var folderSearchText = ""
@State private var folderSort: ScanFolderPickerSort = .nameAscending
private var currentID: String? { path.last?.id }
private var currentName: String { path.isEmpty ? "整个云盘" : path.map(\.name).joined(separator: " / ") }
private var visibleFolders: [CloudFile] {
let query = folderSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
let filtered = query.isEmpty
? model.scanPickerFolders
: model.scanPickerFolders.filter { $0.name.localizedCaseInsensitiveContains(query) }
return filtered.sorted { lhs, rhs in
switch folderSort {
case .nameAscending:
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
case .nameDescending:
return lhs.name.localizedStandardCompare(rhs.name) == .orderedDescending
case .lengthAscending:
return lhs.name.count == rhs.name.count
? lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
: lhs.name.count < rhs.name.count
case .lengthDescending:
return lhs.name.count == rhs.name.count
? lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
: lhs.name.count > rhs.name.count
}
}
}
var body: some View {
ZStack {
Color(nsColor: .underPageBackgroundColor).ignoresSafeArea()
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 13) {
Image(systemName: "folder.badge.gearshape")
.font(.title2.weight(.semibold))
.foregroundStyle(.orange)
.frame(width: 46, height: 46)
.background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.title2.weight(.bold))
Text("打开文件夹继续向下浏览,在底部选择当前位置。")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { dismiss() } label: { Image(systemName: "xmark") }
.buttonStyle(.bordered).help("关闭")
}
folderBreadcrumb
if allowsFilteringAndSorting {
HStack(spacing: 10) {
Label("当前文件夹", systemImage: "folder")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
TextField("筛选目录", text: $folderSearchText)
.textFieldStyle(.roundedBorder)
Menu {
Picker("排序", selection: $folderSort) {
ForEach(ScanFolderPickerSort.allCases) { option in
Text(option.title).tag(option)
}
}
} label: {
Label("排序", systemImage: "arrow.up.arrow.down")
}
.menuStyle(.borderlessButton)
}
}
Group {
if model.isLoadingScanPicker {
VStack(spacing: 12) { ProgressView(); Text("正在读取文件夹…").foregroundStyle(.secondary) }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if visibleFolders.isEmpty {
ContentUnavailableView("没有子文件夹", systemImage: "folder", description: Text("可以选择当前文件夹作为扫描范围。"))
} else {
List(visibleFolders) { folder in
Button { open(folder) } label: {
HStack(spacing: 12) {
Image(systemName: "folder.fill")
.font(.title3).foregroundStyle(.orange)
.frame(width: 32, height: 32)
.background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 9, style: .continuous))
Text(folder.name).font(.body.weight(.medium)).lineLimit(1)
Spacer()
Image(systemName: "chevron.right").font(.caption.weight(.bold)).foregroundStyle(.tertiary)
}
.padding(.vertical, 5)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.listStyle(.inset)
.scrollContentBackground(.hidden)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(.white.opacity(0.32), lineWidth: 1) }
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("当前位置").font(.caption).foregroundStyle(.secondary)
Text(currentName).font(.callout.weight(.semibold)).lineLimit(1)
}
Spacer()
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
Button {
onSelect(currentID, currentName)
dismiss()
} label: {
Label(path.isEmpty ? "选择整个云盘" : selectionLabel, systemImage: "checkmark")
}
.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction)
}
}
.padding(24)
}
.frame(minWidth: 640, minHeight: 560)
.task { await model.loadScanPickerFolders() }
}
private var folderBreadcrumb: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
Button { navigateToRoot() } label: { Label("整个云盘", systemImage: "externaldrive.fill") }
.buttonStyle(.plain).foregroundStyle(path.isEmpty ? .primary : .secondary)
ForEach(Array(path.enumerated()), id: \.element.id) { index, folder in
Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary)
Button(folder.name) { navigate(to: index) }
.buttonStyle(.plain)
.foregroundStyle(index == path.count - 1 ? .primary : .secondary)
.fontWeight(index == path.count - 1 ? .semibold : .regular)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
}
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 11, style: .continuous))
}
private func open(_ folder: CloudFile) {
guard !model.isLoadingScanPicker else { return }
path.append(FolderPath(id: folder.id, name: folder.name))
folderSearchText = ""
Task { await model.loadScanPickerFolders(parentID: folder.id) }
}
private func navigateToRoot() {
guard !model.isLoadingScanPicker else { return }
path = []
folderSearchText = ""
Task { await model.loadScanPickerFolders() }
}
private func navigate(to index: Int) {
guard !model.isLoadingScanPicker, path.indices.contains(index) else { return }
path.removeSubrange((index + 1)..<path.count)
folderSearchText = ""
Task { await model.loadScanPickerFolders(parentID: path[index].id) }
}
}
private struct ScanToolPage: View {
@ObservedObject var model: AppModel
let kind: ScanKind
@State private var selectedIDs: Set<String> = []
@State private var automaticallySelectedSimilarIDs: Set<String> = []
@State private var showDeletionConfirmation = false
@State private var detailFolder: CloudFile?
@State private var duplicateQuickSelectMinimumMB = 100
private var resultItems: [ScanItem] {
if model.activeScanRequest?.kind == kind, model.isScanning { return model.liveScanItems }
return model.scanResult?.request.kind == kind ? model.scanResult?.items ?? [] : []
}
private var completedResult: ScanResult? {
guard model.scanResult?.request.kind == kind else { return nil }
return model.scanResult
}
private var selectedDuplicateSize: Int64 {
resultItems.reduce(into: Int64(0)) { total, item in
guard case .duplicate(let group) = item else { return }
total += group.files.filter { selectedIDs.contains($0.id) }.compactMap(\.size).reduce(0, +)
}
}
private var selectedDuplicateSizeText: String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: selectedDuplicateSize)
}
var body: some View {
ToolPageCard(icon: kind.icon, tint: scanTint, title: "\(kind.title)扫描结果", detail: resultSummary) {
scanStatus
if model.isScanning { Divider() }
if resultItems.isEmpty {
EmptyState(icon: kind.icon, title: model.isScanning ? "正在扫描\(kind.title)" : "没有发现\(kind.title)", subtitle: model.isScanning ? "符合当前扫描类型的项目会显示在这里。" : "本次扫描没有发现符合条件的项目。")
} else {
if kind == .emptyFolders {
HStack {
Toggle("全选空文件夹", isOn: Binding(get: { emptyFolderIDs.isSubset(of: selectedIDs) && !emptyFolderIDs.isEmpty }, set: { selectedIDs = $0 ? selectedIDs.union(emptyFolderIDs) : selectedIDs.subtracting(emptyFolderIDs) }))
Spacer()
Text("已选 \(selectedIDs.intersection(emptyFolderIDs).count) 个").font(.caption.weight(.semibold)).foregroundStyle(.orange)
emptyQuickSelectionMenu
}
}
else if kind == .duplicates {
HStack {
Text("勾选项将被删除;每组始终至少保留一个文件。").font(.caption).foregroundStyle(.secondary)
Spacer()
Text("已选 \(selectedFileIDsForDeletion.count) 项 · \(selectedDuplicateSizeText)").font(.caption.weight(.semibold)).monospacedDigit().foregroundStyle(.blue)
duplicateQuickSelectionMenu
}
} else {
HStack {
Text("每组已默认勾选除第一项外的文件夹;请根据完整路径复核保留项。").font(.caption).foregroundStyle(.secondary)
Spacer()
Text("已选 \(selectedFileIDsForDeletion.count) 个").font(.caption.weight(.semibold)).foregroundStyle(.green)
similarQuickSelectionMenu
}
}
ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(resultItems) { item in scanResultRow(item) } }.padding(.vertical, 2) }
.frame(maxHeight: .infinity)
Button(role: .destructive) { showDeletionConfirmation = true } label: {
HStack(spacing: 7) {
if model.isDeletingScanResults { ProgressView().controlSize(.small) }
Label(model.isDeletingScanResults ? deletionButtonTitle : (kind == .emptyFolders ? "删除已选 \(selectedFileIDsForDeletion.count) 个空文件夹" : "删除已选 \(selectedFileIDsForDeletion.count) 项"), systemImage: "trash")
}
}.buttonStyle(.borderedProminent).disabled(selectedFileIDsForDeletion.isEmpty || model.isScanning || model.isDeletingScanResults)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.confirmationDialog(kind == .emptyFolders ? "确认删除已选空文件夹?" : "确认删除已选项目?", isPresented: $showDeletionConfirmation) {
Button(kind == .emptyFolders ? "删除 \(selectedFileIDsForDeletion.count) 个文件夹" : "删除 \(selectedFileIDsForDeletion.count) 项", role: .destructive) {
let ids = selectedFileIDsForDeletion
Task {
if kind == .emptyFolders { await model.deleteScannedEmptyFolders(ids) }
else { await model.deleteScannedFiles(ids) }
selectedIDs.subtract(ids)
}
}
} message: { Text(kind == .emptyFolders ? "执行前会逐个重新验证为空,发生变化的目录会被跳过。" : "此操作不可撤销。请确认每个分组至少保留一项。") }
.sheet(item: $detailFolder) { FileDetailsSheet(model: model, file: $0) }
.onAppear { autoSelectSimilarFolders() }
.onChange(of: scanItemSignature) { _, _ in autoSelectSimilarFolders() }
.onChange(of: model.scanResult?.id) { _, resultID in
if resultID == nil && model.isScanning {
selectedIDs.removeAll()
automaticallySelectedSimilarIDs.removeAll()
}
}
}
private var resultSummary: String {
if model.isScanning { return "正在扫描\(kind.title) · \(model.activeScanRequest?.rootName ?? "所选范围"),已发现 \(resultItems.count) 项。" }
if let completedResult { return "已扫描 \(completedResult.foldersScanned) 个文件夹、\(completedResult.filesScanned) 个文件,发现 \(resultItems.count) 项。" }
return model.scanProgress.phase
}
private var scanStatus: some View {
HStack(spacing: 10) {
if model.isScanning || model.isDeletingScanResults { ProgressView().controlSize(.small) }
VStack(alignment: .leading, spacing: 2) {
Text(model.isDeletingScanResults ? model.scanDeletionProgress.phase : model.scanProgress.phase).font(.callout.weight(.semibold))
if model.isDeletingScanResults {
Text("已完成 \(model.scanDeletionProgress.completed) / \(model.scanDeletionProgress.total)").font(.caption).foregroundStyle(.secondary).monospacedDigit()
} else {
Text("文件夹 \(model.scanProgress.foldersVisited) · 文件 \(model.scanProgress.filesVisited)").font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
if model.isScanning { Button("取消扫描") { model.cancelScan() }.buttonStyle(.bordered) }
}
.padding(10)
.background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var deletionButtonTitle: String {
"正在删除 \(model.scanDeletionProgress.completed)/\(model.scanDeletionProgress.total)"
}
private var emptyFolderIDs: Set<String> {
Set(resultItems.compactMap { item in
guard case .emptyFolder(let folder) = item else { return nil }
return folder.id
})
}
private var scanItemSignature: String {
resultItems.map { item in
switch item {
case .emptyFolder(let folder): return folder.id
case .duplicate(let group): return group.files.map(\.id).joined(separator: ",")
case .similar(let group): return group.folders.map(\.id).joined(separator: ",")
}
}
.joined(separator: "|")
}
private var scanTint: Color {
switch kind {
case .emptyFolders: return .orange
case .duplicates: return .blue
case .similarFolders: return .green
}
}
private var duplicateQuickSelectionMenu: some View {
Menu {
Button("保留路径最短,选择其余项") { selectDuplicateFilesKeeping { shortestPathFile(in: $0) } }
Button("保留路径最长,选择其余项") { selectDuplicateFilesKeeping { longestPathFile(in: $0) } }
Divider()
Button("保留最大文件,选择其余项") { selectDuplicateFilesKeeping { $0.max { ($0.size ?? 0) < ($1.size ?? 0) } } }
Button("保留最新文件,选择其余项") { selectDuplicateFilesKeeping { $0.max { $0.modifiedAt < $1.modifiedAt } } }
Divider()
Stepper("阈值:\(duplicateQuickSelectMinimumMB) MB", value: $duplicateQuickSelectMinimumMB, in: 1...50_000, step: 10)
Button("选择大于 \(duplicateQuickSelectMinimumMB) MB 的文件") { selectDuplicateFilesLargerThan(Int64(duplicateQuickSelectMinimumMB) * 1_024 * 1_024) }
Divider()
Button("清除快速选择") { clearDuplicateSelection() }
} label: {
Label("快速选择", systemImage: "wand.and.stars")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
private var emptyQuickSelectionMenu: some View {
Menu {
Button("选择全部空文件夹") { selectedIDs.formUnion(emptyFolderIDs) }
Button("清除空文件夹选择") { selectedIDs.subtract(emptyFolderIDs) }
} label: {
Label("快速选择", systemImage: "wand.and.stars")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
private var similarQuickSelectionMenu: some View {
Menu {
Button("保留路径最短,选择其余项") { selectSimilarFoldersKeeping { shortestPathFile(in: $0) } }
Button("保留路径最长,选择其余项") { selectSimilarFoldersKeeping { longestPathFile(in: $0) } }
Divider()
Button("恢复默认选择") { autoSelectSimilarFolders(force: true) }
Button("清除相似文件夹选择") { clearSimilarSelection() }
} label: {
Label("快速选择", systemImage: "wand.and.stars")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
private func selectDuplicateFilesKeeping(_ choose: ([CloudFile]) -> CloudFile?) {
for item in resultItems {
guard case .duplicate(let group) = item, let kept = choose(group.files) else { continue }
let groupIDs = Set(group.files.map(\.id))
selectedIDs.subtract(groupIDs)
selectedIDs.formUnion(groupIDs.subtracting([kept.id]))
}
}
private func clearDuplicateSelection() {
let ids = Set(resultItems.flatMap { item -> [String] in
guard case .duplicate(let group) = item else { return [] }
return group.files.map(\.id)
})
selectedIDs.subtract(ids)
}
private func selectDuplicateFilesLargerThan(_ minimumSize: Int64) {
for item in resultItems {
guard case .duplicate(let group) = item else { continue }
let groupIDs = Set(group.files.map(\.id))
var targets = group.files.filter { ($0.size ?? 0) > minimumSize }
// A duplicate group must retain one resource even when every file
// is above the configured threshold.
if targets.count == group.files.count,
let keep = group.files.min(by: { ($0.size ?? 0) < ($1.size ?? 0) }) {
targets.removeAll { $0.id == keep.id }
}
selectedIDs.subtract(groupIDs)
selectedIDs.formUnion(targets.map(\.id))
}
}
private func selectSimilarFoldersKeeping(_ choose: ([CloudFile]) -> CloudFile?) {
for item in resultItems {
guard case .similar(let group) = item, let kept = choose(group.folders) else { continue }
let groupIDs = Set(group.folders.map(\.id))
selectedIDs.subtract(groupIDs)
selectedIDs.formUnion(groupIDs.subtracting([kept.id]))
automaticallySelectedSimilarIDs.formUnion(groupIDs)
}
}
private func clearSimilarSelection() {
let ids = Set(resultItems.flatMap { item -> [String] in
guard case .similar(let group) = item else { return [] }
return group.folders.map(\.id)
})
selectedIDs.subtract(ids)
}
private func autoSelectSimilarFolders(force: Bool = false) {
guard kind == .similarFolders else { return }
if force {
clearSimilarSelection()
automaticallySelectedSimilarIDs.removeAll()
}
for item in resultItems {
guard case .similar(let group) = item else { continue }
for folder in group.folders.dropFirst() where !automaticallySelectedSimilarIDs.contains(folder.id) {
selectedIDs.insert(folder.id)
automaticallySelectedSimilarIDs.insert(folder.id)
}
}
}
private func shortestPathFile(in files: [CloudFile]) -> CloudFile? {
files.min {
if $0.pathDepth != $1.pathDepth { return $0.pathDepth < $1.pathDepth }
if $0.cloudPath.count != $1.cloudPath.count { return $0.cloudPath.count < $1.cloudPath.count }
return $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending
}
}
private func longestPathFile(in files: [CloudFile]) -> CloudFile? {
files.max {
if $0.pathDepth != $1.pathDepth { return $0.pathDepth < $1.pathDepth }
if $0.cloudPath.count != $1.cloudPath.count { return $0.cloudPath.count < $1.cloudPath.count }
return $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending
}
}
private func duplicateSelectionBinding(for file: CloudFile, in group: DuplicateGroup) -> Binding<Bool> {
Binding(
get: { selectedIDs.contains(file.id) },
set: { shouldDelete in
if !shouldDelete { selectedIDs.remove(file.id); return }
let selectedInGroup = group.files.filter { selectedIDs.contains($0.id) }.count
if selectedInGroup < group.files.count - 1 { selectedIDs.insert(file.id) }
}
)
}
private func similarSelectionBinding(for folder: CloudFile, in group: SimilarFolderGroup) -> Binding<Bool> {
Binding(
get: { selectedIDs.contains(folder.id) },
set: { shouldDelete in
if !shouldDelete { selectedIDs.remove(folder.id); return }
let selectedInGroup = group.folders.filter { selectedIDs.contains($0.id) }.count
if selectedInGroup < group.folders.count - 1 { selectedIDs.insert(folder.id) }
}
)
}
private var selectedFileIDsForDeletion: Set<String> {
Set(resultItems.flatMap { item -> [String] in
switch item {
case .emptyFolder(let folder): return selectedIDs.contains(item.id) ? [folder.id] : []
case .duplicate(let group): return group.files.filter { selectedIDs.contains($0.id) }.map(\.id)
case .similar(let group): return group.folders.filter { selectedIDs.contains($0.id) }.map(\.id)
}
})
}
@ViewBuilder private func scanResultRow(_ item: ScanItem) -> some View {
switch item {
case .emptyFolder(let folder):
Toggle(isOn: Binding(get: { selectedIDs.contains(folder.id) }, set: { if $0 { selectedIDs.insert(folder.id) } else { selectedIDs.remove(folder.id) } })) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "folder").foregroundStyle(.orange).frame(width: 22)
VStack(alignment: .leading, spacing: 3) {
Text(folder.name).font(.callout.weight(.medium)).lineLimit(1)
Text(folder.cloudPath).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled)
}
Spacer()
VStack(alignment: .trailing, spacing: 3) {
Text("空文件夹").font(.caption.weight(.semibold)).foregroundStyle(.orange)
Text(folder.id).font(.caption2.monospaced()).foregroundStyle(.tertiary)
}
}
}
.padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12))
.contextMenu {
Button { detailFolder = folder } label: { Label("查看详情", systemImage: "info.circle") }
Button(role: .destructive) { selectedIDs.insert(item.id); showDeletionConfirmation = true } label: { Label("删除此空文件夹…", systemImage: "trash") }
}
case .duplicate(let group):
VStack(alignment: .leading, spacing: 8) {
HStack { Text("重复组 · \(group.files.count) 项").font(.caption.weight(.bold)).foregroundStyle(.blue); Spacer(); Text("GCID \(group.id)").font(.caption2.monospaced()).foregroundStyle(.tertiary).lineLimit(1) }
ForEach(group.files) { file in
let selected = selectedIDs.contains(file.id)
Toggle(isOn: duplicateSelectionBinding(for: file, in: group)) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: file.icon).foregroundStyle(.blue).frame(width: 22)
VStack(alignment: .leading, spacing: 3) {
Text(file.name).font(.callout.weight(.medium)).lineLimit(1)
Text(file.cloudPath).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled)
}
Spacer()
VStack(alignment: .trailing, spacing: 3) {
Text(file.formattedSize).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Text(selected ? "将删除" : "保留").font(.caption.weight(.semibold)).foregroundStyle(selected ? .red : .green)
}
}
}
.disabled(!selected && group.files.filter { !selectedIDs.contains($0.id) }.count <= 1)
}
}.padding(12).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12))
case .similar(let group):
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("相似名称组 · \(group.folders.count) 个文件夹").font(.caption.weight(.bold)).foregroundStyle(.green)
Spacer()
Text("自动保留第一项").font(.caption2).foregroundStyle(.secondary)
}
ForEach(group.folders) { folder in
let selected = selectedIDs.contains(folder.id)
Toggle(isOn: similarSelectionBinding(for: folder, in: group)) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "folder.fill").foregroundStyle(.orange).frame(width: 22)
VStack(alignment: .leading, spacing: 3) {
Text(folder.name).font(.callout.weight(.medium)).lineLimit(1)
Text(folder.cloudPath).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled)
}
Spacer()
Text(selected ? "将删除" : "保留")
.font(.caption.weight(.semibold))
.foregroundStyle(selected ? .red : .green)
}
}
}
}.padding(12).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12))
}
}
}
private struct CleanupToolPage: View {
@ObservedObject var model: AppModel
@State private var confirmed = false
var body: some View {
ToolPageCard(icon: "trash.slash", tint: .orange, title: "清理范围", detail: "将从云盘根目录递归扫描。只有完全没有子文件和子目录的文件夹会被删除。") {
Toggle("我已了解该操作会删除空文件夹", isOn: $confirmed)
Button { Task { await model.cleanEmptyFolders() } } label: { Label(model.isBusy ? "正在清理…" : "开始扫描并清理", systemImage: "sparkles") }.buttonStyle(.borderedProminent).tint(.orange).disabled(!confirmed || model.isBusy)
}
}
}
private struct DuplicateToolPage: View {
@ObservedObject var model: AppModel
var body: some View {
VStack(spacing: 16) {
ToolPageCard(icon: "square.on.square", tint: .blue, title: "扫描选项", detail: "扫描当前列表中的文件,按 GCID 归组。") {
HStack {
Button { Task { await model.scanDuplicates() } } label: { Label("扫描全部文件", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).tint(.blue)
Button { Task { await model.scanDuplicates(excludingSmallFiles: true) } } label: { Label("排除小于 1 MB", systemImage: "line.3.horizontal.decrease.circle") }.buttonStyle(.bordered)
if model.isAnalyzing { ProgressView().controlSize(.small) }
}
}
ToolPageCard(icon: "list.bullet.rectangle", tint: .purple, title: "扫描结果", detail: model.duplicateGroups.isEmpty ? "尚未发现重复文件" : "发现 \(model.duplicateGroups.count) 组重复文件") {
if model.duplicateGroups.isEmpty { EmptyState(icon: "checkmark.seal", title: "暂无重复项", subtitle: "点击上方按钮开始扫描当前列表。") }
else { ForEach(model.duplicateGroups) { group in DuplicateGroupRow(group: group) } }
}
}
}
}
private struct DuplicateGroupRow: View {
let group: DuplicateGroup
var body: some View {
VStack(alignment: .leading, spacing: 9) {
HStack { Text("GCID \(group.id)").font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1); Spacer(); Text("\(group.files.count) 个").font(.caption.weight(.bold)).foregroundStyle(.orange) }
ForEach(group.files) { file in HStack { Image(systemName: file.icon).frame(width: 22); Text(file.name).lineLimit(1); Spacer(); Text(file.formattedSize).foregroundStyle(.secondary) }.font(.callout) }
}.padding(13).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
private struct SimilarFoldersToolPage: View {
@ObservedObject var model: AppModel
var body: some View {
ToolPageCard(icon: "rectangle.3.group", tint: .green, title: "当前目录相似文件夹", detail: "忽略空格、点号、括号、连字符和大小写后比较名称。") {
Button { model.findSimilarFolders() } label: { Label("查找相似文件夹", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).tint(.green)
if model.similarFolders.isEmpty { EmptyState(icon: "folder.badge.questionmark", title: "暂无结果", subtitle: "点击按钮扫描当前目录。") }
else { ForEach(model.similarFolders) { folder in HStack { Image(systemName: "folder.fill").foregroundStyle(.orange); Text(folder.name); Spacer(); Text(folder.formattedSize).foregroundStyle(.secondary) }.padding(11).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) } }
}
}
}
struct TMDBToolPage: View {
@ObservedObject var model: AppModel
@State private var workflow = TMDBWorkflowConfig.load()
@State private var sourceID: String?
@State private var sourceName = "云盘根目录"
@State private var destinationID: String?
@State private var destinationName = "云盘根目录"
@State private var showRunConfirmation = false
var body: some View {
VStack(spacing: 16) {
ToolPageCard(icon: "film.stack", tint: .purple, title: "TMDB 刮削任务", detail: "按“选择范围 → 建立任务 → 预览匹配 → 确认执行”运行;预览阶段不会修改云盘。") {
HStack { Image(systemName: model.tmdbAPIKey.isEmpty ? "xmark.circle.fill" : "checkmark.circle.fill").foregroundStyle(model.tmdbAPIKey.isEmpty ? .red : .green); Text(model.tmdbAPIKey.isEmpty ? "请先在影视模式侧栏配置 TMDB API Key" : "TMDB API Key 已配置") }
Picker("媒体类型", selection: $workflow.mediaKind) { ForEach(TMDBMediaKind.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented)
Picker("执行方式", selection: $workflow.operation) { ForEach(TMDBOperation.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented)
scopePicker(title: "源目录", name: $sourceName, id: $sourceID)
if workflow.operation == .organize { scopePicker(title: "目标目录", name: $destinationName, id: $destinationID) }
Toggle("递归扫描源目录", isOn: $workflow.sourceRecursive)
HStack { TextField("文件夹模板,例如 {title} ({year})", text: $workflow.folderTemplate); TextField("文件模板", text: $workflow.fileTemplate) }.textFieldStyle(.roundedBorder)
HStack { TextField("扩展名,逗号分隔", text: $workflow.allowedExtensions); Stepper("最小体积 \(workflow.minimumSizeMB) MB", value: $workflow.minimumSizeMB, in: 0...20_000, step: 50) }.textFieldStyle(.roundedBorder)
HStack { Toggle("写入 NFO", isOn: $workflow.createNFO); Toggle("下载海报(即将支持)", isOn: $workflow.downloadPoster).disabled(true); Toggle("清理空目录(即将支持)", isOn: $workflow.cleanEmptyFolders).disabled(true) }
HStack { Button("保存配置") { model.saveTMDBWorkflow(workflow) }.buttonStyle(.bordered); Spacer(); Button { model.saveTMDBWorkflow(workflow); Task { await model.prepareTMDBJobs(sourceID: sourceID, recursive: workflow.sourceRecursive, minimumSizeMB: workflow.minimumSizeMB, extensionsText: workflow.allowedExtensions) } } label: { Label("建立任务", systemImage: "list.bullet.clipboard") }.buttonStyle(.borderedProminent).tint(.purple).disabled(model.tmdbAPIKey.isEmpty || model.isRunningTMDBJob) }
}
ToolPageCard(icon: "checklist", tint: .blue, title: "任务预览与执行", detail: model.tmdbJobs.isEmpty ? "尚未建立任务。" : "共 \(model.tmdbJobs.count) 项;先匹配,再确认执行。") {
if model.tmdbJobs.isEmpty { EmptyState(icon: "film", title: "等待建立任务", subtitle: "任务会按扩展名、最小体积和扫描范围过滤。") }
else {
HStack { Button { Task { await model.matchTMDBJobs() } } label: { Label("搜索候选", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).disabled(model.isRunningTMDBJob); Button { showRunConfirmation = true } label: { Label(workflow.operation == .preview ? "预览模式,不执行整理" : "确认执行整理", systemImage: "play.fill") }.buttonStyle(.bordered).disabled(model.isRunningTMDBJob || workflow.operation == .preview); if model.isRunningTMDBJob { ProgressView().controlSize(.small) } }
ForEach(model.tmdbJobs) { job in
VStack(alignment: .leading, spacing: 8) {
HStack { Image(systemName: job.state == .completed ? "checkmark.circle.fill" : (job.state == .failed ? "xmark.circle.fill" : "film" )).foregroundStyle(job.state == .failed ? .red : .purple); VStack(alignment: .leading) { Text(job.file.name).lineLimit(1); Text(job.note).font(.caption).foregroundStyle(.secondary).lineLimit(1) }; Spacer(); if job.selectedCandidate != nil { Toggle("执行", isOn: Binding(get: { job.isApproved }, set: { _ in model.toggleTMDBApproval(jobID: job.id) })).toggleStyle(.switch).labelsHidden() } }
if !job.candidates.isEmpty { Picker("选择 TMDB 匹配", selection: Binding(get: { job.selectedCandidateID }, set: { model.selectTMDBCandidate(jobID: job.id, candidateID: $0) })) { Text("跳过此项").tag(Int?.none); ForEach(job.candidates) { candidate in Text("\(candidate.title) \(candidate.releaseDate.prefix(4)) · \(candidate.mediaType.title)").tag(Optional(candidate.id)) } }.labelsHidden() }
if let candidate = job.selectedCandidate { Text(candidate.overview.isEmpty ? "暂无简介" : candidate.overview).font(.caption).foregroundStyle(.secondary).lineLimit(2) }
}.padding(11).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 11))
}
}
}
}
.confirmationDialog("确认执行已选择的 TMDB 整理任务?", isPresented: $showRunConfirmation) {
Button("开始整理", role: .destructive) { Task { await model.runTMDBJobs(destinationID: destinationID) } }
} message: { Text("只会处理你手动选择并开启“执行”的条目。媒体文件将移动到目标目录;NFO 会在移动后写入。") }
.onAppear {
workflow = model.tmdbWorkflow
syncTMDBTarget()
}
.onChange(of: model.tmdbTarget?.id) { _, _ in syncTMDBTarget() }
}
private func syncTMDBTarget() {
guard let target = model.tmdbTarget, target.isDirectory else { return }
sourceID = target.id
sourceName = target.name
}
private func scopePicker(title: String, name: Binding<String>, id: Binding<String?>) -> some View {
HStack { VStack(alignment: .leading, spacing: 2) { Text(title).font(.caption).foregroundStyle(.secondary); Text(name.wrappedValue).font(.callout.weight(.semibold)).lineLimit(1) }; Spacer(); Button("使用当前目录") { id.wrappedValue = model.currentScanRootID; name.wrappedValue = model.scanScopeLocationName }.buttonStyle(.bordered); Button("根目录") { id.wrappedValue = nil; name.wrappedValue = "云盘根目录" }.buttonStyle(.bordered) }.padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12))
}
}
private struct ToolSettingsPage: View {
@ObservedObject var model: AppModel
@State private var key = ""
@State private var proxyHost = ""
@State private var proxyPort = ""
private var isProxyValid: Bool {
proxyConfigurationIsValid(host: proxyHost, port: proxyPort)
}
var body: some View {
ToolPageCard(icon: "gearshape.2", tint: .gray, title: "本机配置", detail: "这些配置仅保存在当前 Mac。") {
SecureField("TMDB API Key", text: $key).textFieldStyle(.roundedBorder)
HStack { TextField("代理地址,例如 127.0.0.1", text: $proxyHost).textFieldStyle(.roundedBorder); TextField("端口", text: $proxyPort).textFieldStyle(.roundedBorder).frame(width: 100) }
if !isProxyValid { Label("请同时填写有效的代理地址和端口", systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.red) }
HStack { Spacer(); Button("清空") { key = ""; proxyHost = ""; proxyPort = "" }; Button("保存配置") { model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort) }.buttonStyle(.borderedProminent).tint(.orange).disabled(!isProxyValid) }
}.onAppear { key = model.tmdbAPIKey; proxyHost = model.tmdbProxyHost; proxyPort = model.tmdbProxyPort }
}
}
struct ToolPageCard<Content: View>: View {
let icon: String
let tint: Color
let title: String
let detail: String
@ViewBuilder let content: Content
init(icon: String, tint: Color, title: String, detail: String, @ViewBuilder content: () -> Content) { self.icon = icon; self.tint = tint; self.title = title; self.detail = detail; self.content = content() }
var body: some View {
VStack(alignment: .leading, spacing: 15) {
HStack(spacing: 11) { Image(systemName: icon).foregroundStyle(tint).frame(width: 36, height: 36).background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)); VStack(alignment: .leading, spacing: 3) { Text(title).font(.headline); Text(detail).font(.caption).foregroundStyle(.secondary) }; Spacer() }
Divider(); content
}.padding(18).softCard(radius: 22)
}
}
struct CreateFolderSheet: View {
@Binding var name: String
let onCreate: (String) -> Void
let onCancel: () -> Void
@FocusState private var focused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 18) {
SheetHeader(icon: "folder.badge.plus", title: "新建文件夹", subtitle: "在当前目录创建一个新的云端文件夹")
TextField("文件夹名称", text: $name)
.textFieldStyle(.plain)
.focused($focused)
.padding(.horizontal, 13)
.frame(height: 42)
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 11).stroke(focused ? Color.orange : Color.black.opacity(0.10), lineWidth: 1) }
.onSubmit { createIfPossible() }
HStack { Spacer(); Button("取消", action: onCancel).keyboardShortcut(.cancelAction); Button("创建") { createIfPossible() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
.padding(24)
.frame(width: 380)
.softCard(radius: 24)
.onAppear { focused = true }
}
private func createIfPossible() {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onCreate(trimmed)
}
}
struct RenameSheet: View {
let file: CloudFile
let onRename: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
@FocusState private var focused: Bool
init(file: CloudFile, onRename: @escaping (String) -> Void) {
self.file = file
self.onRename = onRename
_name = State(initialValue: file.name)
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
SheetHeader(icon: "pencil", title: "重命名", subtitle: file.name)
TextField("名称", text: $name)
.textFieldStyle(.plain)
.focused($focused)
.padding(.horizontal, 13)
.frame(height: 42)
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 11).stroke(focused ? Color.orange : Color.black.opacity(0.10), lineWidth: 1) }
.onSubmit { saveIfPossible() }
HStack { Spacer(); Button("取消") { dismiss() }.keyboardShortcut(.cancelAction); Button("保存") { saveIfPossible() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || name == file.name) }
}
.padding(24)
.frame(width: 400)
.softCard(radius: 24)
.onAppear { focused = true }
}
private func saveIfPossible() {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onRename(trimmed)
dismiss()
}
}
struct WorkspaceSettings: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var key = ""
@State private var proxyHost = ""
@State private var proxyPort = ""
private var isProxyValid: Bool {
proxyConfigurationIsValid(host: proxyHost, port: proxyPort)
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
SheetHeader(icon: "gearshape.2.fill", title: "工作区设置", subtitle: "配置本机工具能力,不会上传到云端")
VStack(alignment: .leading, spacing: 10) {
Label("TMDB 识别", systemImage: "film.fill").font(.caption.weight(.bold)).foregroundStyle(.secondary)
SecureField("本地记住的 API Key", text: $key).textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 10) {
Label("HTTP 代理", systemImage: "network").font(.caption.weight(.bold)).foregroundStyle(.secondary)
HStack(spacing: 8) {
TextField("地址,例如 127.0.0.1", text: $proxyHost).textFieldStyle(.roundedBorder)
TextField("端口", text: $proxyPort).textFieldStyle(.roundedBorder).frame(width: 88)
}
Text("代理仅用于 TMDB 请求。留空表示直连。")
.font(.caption).foregroundStyle(.secondary)
if !isProxyValid {
Label("请同时填写代理地址和 1-65535 之间的端口", systemImage: "exclamationmark.triangle.fill")
.font(.caption).foregroundStyle(.red)
}
}
HStack { Spacer(); Button("取消") { dismiss() }.keyboardShortcut(.cancelAction); Button("保存") { model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort); dismiss() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(!isProxyValid) }
}
.padding(24)
.frame(width: 520)
.softCard(radius: 24)
.onAppear { key = model.tmdbAPIKey; proxyHost = model.tmdbProxyHost; proxyPort = model.tmdbProxyPort }
}
}
struct DuplicateResults: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack { SheetHeader(icon: "square.on.square", title: "重复文件", subtitle: model.duplicateGroups.isEmpty ? "按 GCID 扫描当前列表" : "发现 \(model.duplicateGroups.count) 组重复资源"); Spacer(); Button("关闭") { dismiss() } }
if model.isAnalyzing {
VStack(spacing: 12) { ProgressView(); Text("正在按 GCID 扫描…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, minHeight: 260)
} else if model.duplicateGroups.isEmpty {
EmptyState(icon: "checkmark.seal", title: "没有发现重复文件", subtitle: "当前目录看起来很干净。")
.frame(maxWidth: .infinity, minHeight: 260)
} else {
List(model.duplicateGroups) { group in
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("GCID").font(.caption.weight(.bold)).foregroundStyle(.secondary)
Text(group.id).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
Spacer()
Text("\(group.files.count) 个").font(.caption.weight(.bold)).foregroundStyle(.orange)
}
ForEach(group.files) { file in
HStack(spacing: 10) {
Image(systemName: file.icon).foregroundStyle(file.isDirectory ? .orange : .accentColor).frame(width: 24)
Text(file.name).lineLimit(1)
Spacer()
Text(file.formattedSize).foregroundStyle(.secondary).monospacedDigit()
}
.font(.callout)
}
}
.padding(.vertical, 8)
}
.listStyle(.inset)
.frame(minHeight: 320)
}
}
.padding(24)
.frame(width: 640, height: 500)
.softCard(radius: 26)
}
}
private struct SheetHeader: View {
let icon: String
let title: String
let subtitle: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title3.weight(.semibold))
.foregroundStyle(.orange)
.frame(width: 42, height: 42)
.background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.title3.bold())
Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
}
}
}
private struct EmptyState: View {
let icon: String
let title: String
let subtitle: String
var body: some View {
VStack(spacing: 12) {
Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.tertiary)
Text(title).font(.title3.weight(.semibold))
Text(subtitle).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
.padding(28)
}
}
private func jsonText(_ value: JSONValue) -> String { guard let data = try? JSONEncoder().encode(value), let object = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), let text = String(data: pretty, encoding: .utf8) else { return "--" }; return text }
private struct EmptyFilesView: View {
@ObservedObject var model: AppModel
var body: some View {
EmptyState(icon: model.section.icon, title: "这里还没有文件", subtitle: model.section == .recycle ? "回收站为空,暂时没有可恢复内容。" : "上传文件或创建文件夹开始使用。")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct EmptySectionView: View {
let section: WorkspaceSection
var body: some View {
EmptyState(icon: section.icon, title: section == .cloud ? "云下载" : "我的分享", subtitle: "接口已就绪,后续可以在这里管理相关内容。")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct BrandMark: View {
let size: CGFloat
var body: some View {
if let path = Bundle.main.path(forResource: "光鸭", ofType: "svg"), let image = NSImage(contentsOfFile: path) { Image(nsImage: image).resizable().aspectRatio(contentMode: .fit).frame(width: size, height: size) }
else { RoundedRectangle(cornerRadius: size * 0.22).fill(Color.orange).frame(width: size, height: size).overlay { Image(systemName: "bird.fill").foregroundStyle(.white) } }
}
}
private struct QRCodeImage: View {
let payload: String
var body: some View { if let image = makeImage() { Image(nsImage: image).interpolation(.none).resizable().scaledToFit() } else { Image(systemName: "qrcode").font(.largeTitle) } }
private func makeImage() -> NSImage? {
let filter = CIFilter.qrCodeGenerator(); filter.message = Data(payload.utf8); filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)); let context = CIContext()
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return NSImage(cgImage: cgImage, size: NSSize(width: 210, height: 210))
}
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
LoginView(model: AppModel(), autoStartQR: false)
.previewDisplayName("登录页")
}
}