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 @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 = [] 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? @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).. = [] @State private var automaticallySelectedSimilarIDs: Set = [] @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 { 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 { 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 { 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 { 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, id: Binding) -> 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: 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("登录页") } }