From fc22f63db4f4fd0bc9ef08ae9f005e4b6dab61bd Mon Sep 17 00:00:00 2001 From: ngfchl Date: Thu, 16 Jul 2026 12:37:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BA=91=E7=9B=98?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E4=B8=8E=E6=89=B9=E9=87=8F=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- guangya_mac.xcodeproj/project.pbxproj | 16 + guangya_mac/AppModel.swift | 240 ++++++-- guangya_mac/AppTheme.swift | 51 ++ guangya_mac/BatchRenameTool.swift | 374 +++++++++++ guangya_mac/ContentView.swift | 857 ++++++++++++++++---------- guangya_mac/FileMetadataCache.swift | 265 ++++++++ guangya_mac/GuangyaAPI.swift | 3 +- guangya_mac/Models.swift | 27 + guangya_mac/WorkspaceShell.swift | 167 +++++ 9 files changed, 1615 insertions(+), 385 deletions(-) create mode 100644 guangya_mac/AppTheme.swift create mode 100644 guangya_mac/BatchRenameTool.swift create mode 100644 guangya_mac/FileMetadataCache.swift create mode 100644 guangya_mac/WorkspaceShell.swift diff --git a/guangya_mac.xcodeproj/project.pbxproj b/guangya_mac.xcodeproj/project.pbxproj index 8fa2196..ee8b98d 100644 --- a/guangya_mac.xcodeproj/project.pbxproj +++ b/guangya_mac.xcodeproj/project.pbxproj @@ -14,6 +14,10 @@ 8C0E0B522C40000100A10001 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B422C40000100A10001 /* Models.swift */; }; 8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */; }; 8C0E0B542C40000100A10001 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B442C40000100A10001 /* AppModel.swift */; }; + 8C0E0B562C40000100A10001 /* FileMetadataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B462C40000100A10001 /* FileMetadataCache.swift */; }; + 8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B472C40000100A10001 /* AppTheme.swift */; }; + 8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B482C40000100A10001 /* WorkspaceShell.swift */; }; + 8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */; }; 8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; }; /* End PBXBuildFile section */ @@ -27,6 +31,10 @@ 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuangyaAPI.swift; sourceTree = ""; }; 8C0E0B442C40000100A10001 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; 8C0E0B452C40000100A10001 /* 光鸭.svg */ = {isa = PBXFileReference; lastKnownFileType = image.svg; path = "光鸭.svg"; sourceTree = ""; }; + 8C0E0B462C40000100A10001 /* FileMetadataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMetadataCache.swift; sourceTree = ""; }; + 8C0E0B472C40000100A10001 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + 8C0E0B482C40000100A10001 /* WorkspaceShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceShell.swift; sourceTree = ""; }; + 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRenameTool.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -61,9 +69,13 @@ children = ( 8C0E0B362C40000100A10001 /* guangya_macApp.swift */, 8C0E0B372C40000100A10001 /* ContentView.swift */, + 8C0E0B472C40000100A10001 /* AppTheme.swift */, + 8C0E0B482C40000100A10001 /* WorkspaceShell.swift */, + 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */, 8C0E0B422C40000100A10001 /* Models.swift */, 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */, 8C0E0B442C40000100A10001 /* AppModel.swift */, + 8C0E0B462C40000100A10001 /* FileMetadataCache.swift */, 8C0E0B452C40000100A10001 /* 光鸭.svg */, 8C0E0B382C40000100A10001 /* Assets.xcassets */, 8C0E0B392C40000100A10001 /* Preview Content */, @@ -155,6 +167,10 @@ 8C0E0B522C40000100A10001 /* Models.swift in Sources */, 8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */, 8C0E0B542C40000100A10001 /* AppModel.swift in Sources */, + 8C0E0B562C40000100A10001 /* FileMetadataCache.swift in Sources */, + 8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */, + 8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */, + 8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/guangya_mac/AppModel.swift b/guangya_mac/AppModel.swift index 9c7683e..8b1184b 100644 --- a/guangya_mac/AppModel.swift +++ b/guangya_mac/AppModel.swift @@ -24,6 +24,7 @@ final class AppModel: ObservableObject { @Published var similarFolders: [CloudFile] = [] @Published var detail: JSONValue? @Published var detailFileID: String? + @Published var detailErrorMessage = "" @Published var lastActionMessage = "" @Published var tmdbAPIKey = UserDefaults.standard.string(forKey: "guangya.tmdbAPIKey") ?? "" @Published var tmdbProxyHost = UserDefaults.standard.string(forKey: "guangya.tmdbProxyHost") ?? "" @@ -31,6 +32,8 @@ final class AppModel: ObservableObject { @Published var tmdbWorkflow = TMDBWorkflowConfig.load() @Published var tmdbJobs: [TMDBJob] = [] @Published var tmdbTarget: CloudFile? + @Published var batchRenameTarget: CloudFile? + @Published var batchRenameSelection: [CloudFile] = [] @Published var isRunningTMDBJob = false @Published var isAnalyzing = false @Published var activeScanRequest: ScanRequest? @@ -40,6 +43,10 @@ final class AppModel: ObservableObject { @Published var isLoadingScanPicker = false @Published var scanProgress = ScanProgress() @Published var isScanning = false + @Published var isDeletingScanResults = false + @Published var scanDeletionProgress = ScanDeletionProgress() + @Published var isBatchRenaming = false + @Published var batchRenameProgress = BatchRenameProgress() @Published var isBusy = false @Published var statusMessage = "" @Published var errorMessage = "" @@ -59,9 +66,11 @@ final class AppModel: ObservableObject { private var listResponseTask: Task? private var activeListRequestID = UUID() private var detailEnrichmentGeneration = UUID() + private var scanPickerRequestID = UUID() private var detailCache: [String: (value: JSONValue, expiresAt: Date)] = [:] private var detailRequestTasks: [String: Task] = [:] private let detailRequestGate = DetailRequestGate(limit: 4) + private let metadataCache = FileMetadataCache.shared private var isHandlingAuthorizationExpiry = false private var scanTask: Task? @@ -72,6 +81,7 @@ final class AppModel: ObservableObject { api = GuangyaAPI(accessToken: access, refreshToken: refresh) if !access.isEmpty || refresh != nil { isSignedIn = true + isLoadingFiles = true Task { await loadAccount() } } } @@ -138,12 +148,12 @@ final class AppModel: ObservableObject { } func loadVisibleItemDetails(_ file: CloudFile) { - guard file.size == nil || file.gcid == nil || file.modifiedAt.isEmpty else { return } + guard needsMetadata(for: file) else { return } guard !detailLoadingIDs.contains(file.id) else { return } detailLoadingIDs.insert(file.id) Task { [weak self] in guard let self else { return } - let detail = try? await self.cachedDetail(for: file.id) + let detail = try? await self.cachedDetail(for: file) guard !Task.isCancelled, let index = self.files.firstIndex(where: { $0.id == file.id }) else { self.detailLoadingIDs.remove(file.id); return } if let size = detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]) { self.files[index].size = size } else if self.files[index].isDirectory { self.files[index].size = 0 } @@ -318,6 +328,54 @@ final class AppModel: ObservableObject { await perform("正在重命名…") { [self] in _ = try await api.fsRename(fileID: file.id, newName: trimmed); await loadFiles() } } + func batchRenameCandidates(parentID: String?, rootName: String, recursive: Bool) async throws -> [CloudFile] { + var queue: [(id: String?, path: [String])] = [(parentID, parentID == nil ? [] : [rootName])] + var result: [CloudFile] = [] + while !queue.isEmpty { + try Task.checkCancellation() + let location = queue.removeFirst() + var children = try await allFiles(parentID: location.id) + for index in children.indices { + children[index].cloudPath = "/" + (location.path + [children[index].name]).joined(separator: "/") + } + result += children + if recursive { + queue += children.filter(\.isDirectory).map { ($0.id, location.path + [$0.name]) } + } + } + return Array(Dictionary(grouping: result, by: \.id).compactMap { $0.value.first }) + .sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending } + } + + func applyBatchRenames(_ changes: [BatchRenameChange]) async -> BatchRenameExecutionResult { + guard !changes.isEmpty, !isBatchRenaming else { return BatchRenameExecutionResult(succeeded: 0, failed: 0) } + isBatchRenaming = true + batchRenameProgress = BatchRenameProgress(completed: 0, total: changes.count) + defer { isBatchRenaming = false } + var succeeded = 0 + var failures: [String] = [] + for change in changes { + batchRenameProgress.currentName = change.file.name + do { + _ = try await api.fsRename(fileID: change.file.id, newName: change.newName) + succeeded += 1 + } catch { + failures.append("\(change.file.name):\(error.localizedDescription)") + if isAuthorizationExpiry(error) { + handleAuthorizationExpiry() + break + } + } + batchRenameProgress.completed += 1 + } + if isSignedIn { await loadFiles(force: true) } + let failed = changes.count - succeeded + lastActionMessage = failures.isEmpty + ? "已完成 \(succeeded) 项重命名" + : "已完成 \(succeeded) 项,失败 \(failed) 项\n" + failures.prefix(5).joined(separator: "\n") + return BatchRenameExecutionResult(succeeded: succeeded, failed: failed) + } + func copy(_ file: CloudFile, to parentID: String?) async { await perform("正在复制…") { [self] in _ = try await api.fsCopy(fileIDs: [file.id], parentID: parentID); await loadFiles() } } @@ -340,19 +398,25 @@ final class AppModel: ObservableObject { // its resource ID so a slower previous selection can never overwrite it. detailFileID = file.id detail = nil + detailErrorMessage = "" detailLoadingIDs.insert(file.id) defer { detailLoadingIDs.remove(file.id) } do { - let value = try await cachedDetail(for: file.id) + let value = try await cachedDetail(for: file) guard !Task.isCancelled, detailFileID == file.id else { return } detail = value } catch is CancellationError { } catch { guard detailFileID == file.id else { return } - detail = nil; lastActionMessage = "详情请求失败:\(error.localizedDescription)" + detail = nil + detailErrorMessage = error.localizedDescription } } + func details(for file: CloudFile, forceRefresh: Bool = false) async throws -> JSONValue { + try await cachedDetail(for: file, forceRefresh: forceRefresh) + } + func share(_ file: CloudFile) async { await perform("正在创建分享…") { [self] in let result = try await api.shareCreate(fileIDs: [file.id], title: file.name) @@ -382,49 +446,92 @@ final class AppModel: ObservableObject { scanTask = Task { [weak self] in guard let self else { return } defer { self.isScanning = false } - do { self.scanResult = try await self.runScan(request) } - catch is CancellationError { self.scanProgress.phase = "扫描已取消" } - catch { self.errorMessage = error.localizedDescription } + do { + self.scanResult = try await self.runScan(request) + self.scanProgress.phase = "扫描完成" + } + catch is CancellationError { + self.scanResult = ScanResult( + request: request, + items: self.liveScanItems, + foldersScanned: self.scanProgress.foldersVisited, + filesScanned: self.scanProgress.filesVisited, + skippedFolders: 0 + ) + self.scanProgress.phase = "扫描已取消,已保留当前结果" + } + catch { + self.scanProgress.phase = "扫描失败" + self.errorMessage = error.localizedDescription + } } } func cancelScan() { scanTask?.cancel() } func loadScanPickerFolders(parentID: String? = nil) async { - guard !isLoadingScanPicker else { return } + let requestID = UUID() + scanPickerRequestID = requestID isLoadingScanPicker = true - defer { isLoadingScanPicker = false } - do { scanPickerFolders = try await allFiles(parentID: parentID, pageSize: 5_000).filter(\.isDirectory) } - catch { errorMessage = error.localizedDescription } + scanPickerFolders = [] + defer { if scanPickerRequestID == requestID { isLoadingScanPicker = false } } + do { + let folders = try await allFiles(parentID: parentID, pageSize: 5_000).filter(\.isDirectory) + guard scanPickerRequestID == requestID else { return } + scanPickerFolders = folders + } catch { + guard scanPickerRequestID == requestID else { return } + errorMessage = error.localizedDescription + } } func deleteScannedEmptyFolders(_ ids: Set) async { - guard let result = scanResult, result.request.kind == .emptyFolders, !ids.isEmpty else { return } + guard let result = scanResult, result.request.kind == .emptyFolders, !ids.isEmpty, !isDeletingScanResults else { return } + let targets = result.emptyFolders.reversed().filter { ids.contains($0.id) } + isDeletingScanResults = true + scanDeletionProgress = ScanDeletionProgress(phase: "正在复核空文件夹…", completed: 0, total: targets.count) + defer { isDeletingScanResults = false } await perform("正在复核并删除空文件夹…") { [self] in - var deleted = 0; var skipped = 0 - for folder in result.emptyFolders.reversed() where ids.contains(folder.id) { + var deletedIDs = Set(); var skipped = 0 + for folder in targets { + scanDeletionProgress.phase = "正在复核 \(folder.name)" let children = try await allFiles(parentID: folder.id) - guard children.isEmpty else { skipped += 1; continue } - actionLoadingIDs.insert(folder.id) - defer { actionLoadingIDs.remove(folder.id) } - _ = try await api.fsDelete(fileIDs: [folder.id]) - deleted += 1 + if children.isEmpty { + scanDeletionProgress.phase = "正在删除 \(folder.name)" + actionLoadingIDs.insert(folder.id) + do { + defer { actionLoadingIDs.remove(folder.id) } + _ = try await api.fsDelete(fileIDs: [folder.id]) + } + deletedIDs.insert(folder.id) + } else { + skipped += 1 + } + scanDeletionProgress.completed += 1 } - scanResult = nil - files.removeAll { ids.contains($0.id) } - lastActionMessage = skipped == 0 ? "已删除 \(deleted) 个空文件夹" : "已删除 \(deleted) 个,跳过 \(skipped) 个已变更文件夹" + let remaining = result.emptyFolders.filter { !deletedIDs.contains($0.id) } + scanResult = ScanResult(request: result.request, items: remaining.map(ScanItem.emptyFolder), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders + skipped) + files.removeAll { deletedIDs.contains($0.id) } + scanDeletionProgress.phase = "删除完成" + lastActionMessage = skipped == 0 ? "已删除 \(deletedIDs.count) 个空文件夹" : "已删除 \(deletedIDs.count) 个,跳过 \(skipped) 个已变更文件夹" } } func deleteScannedFiles(_ ids: Set) async { - guard let result = scanResult, result.request.kind != .emptyFolders, !ids.isEmpty else { return } + guard let result = scanResult, result.request.kind != .emptyFolders, !ids.isEmpty, !isDeletingScanResults else { return } + isDeletingScanResults = true + scanDeletionProgress = ScanDeletionProgress(phase: "正在删除已选项目…", completed: 0, total: ids.count) + defer { isDeletingScanResults = false } await perform("正在删除已选文件…") { [self] in var deleted = 0 for id in ids { actionLoadingIDs.insert(id) - defer { actionLoadingIDs.remove(id) } - _ = try await api.fsDelete(fileIDs: [id]) + do { + defer { actionLoadingIDs.remove(id) } + _ = try await api.fsDelete(fileIDs: [id]) + } deleted += 1 + scanDeletionProgress.completed = deleted } files.removeAll { ids.contains($0.id) } switch result.request.kind { @@ -444,6 +551,7 @@ final class AppModel: ObservableObject { scanResult = ScanResult(request: result.request, items: groups.map(ScanItem.similar), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders) case .emptyFolders: break } + scanDeletionProgress.phase = "删除完成" lastActionMessage = "已删除 \(deleted) 项" } } @@ -451,7 +559,8 @@ final class AppModel: ObservableObject { private func runScan(_ request: ScanRequest) async throws -> ScanResult { let root: String? = request.scope == .wholeDrive ? nil : request.rootID let recursive = request.scope != .currentFolder - let snapshot = try await scanTree(parentID: root, recursive: recursive) + let rootPath = root == nil ? [] : request.rootName.split(separator: "/").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let snapshot = try await scanTree(parentID: root, recursive: recursive, pathComponents: rootPath) switch request.kind { case .emptyFolders: let candidates = snapshot.folders.filter { $0.isEmptyAfterCandidates } @@ -463,9 +572,13 @@ final class AppModel: ObservableObject { if let minimum = request.excludeFilesSmallerThan { candidates = candidates.filter { ($0.size ?? 0) >= minimum } } let detailed = await withTaskGroup(of: CloudFile.self, returning: [CloudFile].self) { group in for file in candidates { - group.addTask { [api] in + group.addTask { [api, metadataCache] in var copy = file - if copy.gcid == nil, let detail = try? await api.fsDetail(fileID: file.id) { copy.gcid = detail.firstStringDeep(["gcid", "gcId", "hash"]) } + if copy.gcid == nil, let detail = try? await api.fsDetail(fileID: file.id) { + copy.gcid = detail.firstStringDeep(["gcid", "gcId", "hash"]) + if let size = detail.firstInt64Deep(["size", "fileSize", "resSize"]) { copy.size = size } + await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail) + } return copy } } @@ -474,7 +587,9 @@ final class AppModel: ObservableObject { var buckets: [String: [CloudFile]] = [:] for file in detailed { if let gcid = file.gcid, !gcid.isEmpty { buckets[gcid, default: []].append(file) } } var groups: [DuplicateGroup] = [] - for (gcid, files) in buckets where files.count > 1 { groups.append(DuplicateGroup(id: gcid, files: files)) } + for (gcid, files) in buckets where files.count > 1 { + groups.append(DuplicateGroup(id: gcid, files: files.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending })) + } groups.sort { $0.files.count > $1.files.count } return ScanResult(request: request, items: groups.map(ScanItem.duplicate), foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0) case .similarFolders: @@ -484,12 +599,18 @@ final class AppModel: ObservableObject { } private struct ScanTree { let allFiles: [CloudFile]; let folders: [(file: CloudFile, isEmptyAfterCandidates: Bool)] } - private func scanTree(parentID: String?, recursive: Bool) async throws -> ScanTree { + private func scanTree(parentID: String?, recursive: Bool, pathComponents: [String]) async throws -> ScanTree { try Task.checkCancellation() - let children = try await allFiles(parentID: parentID) + var children = try await allFiles(parentID: parentID) + for index in children.indices { + children[index].cloudPath = "/" + (pathComponents + [children[index].name]).joined(separator: "/") + } // A directory whose content request is empty is immediately publishable. // This keeps the scan result live while deeper recursion continues. - if let parentID, children.isEmpty, let parent = files.first(where: { $0.id == parentID }) ?? liveScanItems.compactMap({ if case .emptyFolder(let file) = $0 { return file }; return nil }).first(where: { $0.id == parentID }) { + if activeScanRequest?.kind == .emptyFolders, + let parentID, + children.isEmpty, + let parent = files.first(where: { $0.id == parentID }) ?? liveScanItems.compactMap({ if case .emptyFolder(let file) = $0 { return file }; return nil }).first(where: { $0.id == parentID }) { let item = ScanItem.emptyFolder(parent) if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) } } @@ -498,7 +619,7 @@ final class AppModel: ObservableObject { var all = children; var candidates: [(CloudFile, Bool)] = [] for folder in children where folder.isDirectory { if recursive { - let subtree = try await scanTree(parentID: folder.id, recursive: true) + let subtree = try await scanTree(parentID: folder.id, recursive: true, pathComponents: pathComponents + [folder.name]) all += subtree.allFiles candidates += subtree.folders let hasNonEmptyChild = subtree.folders.contains { $0.file.id == folder.id && !$0.isEmptyAfterCandidates } @@ -537,7 +658,8 @@ final class AppModel: ObservableObject { // Let SwiftUI process pending input/drawing between very large pages. await Task.yield() } - return Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first }) + let unique = Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first }) + return await hydrateFromMetadataCache(unique) } func scanDuplicates(excludingSmallFiles: Bool = false) async { @@ -548,11 +670,13 @@ final class AppModel: ObservableObject { let api = self.api let detailed = await withTaskGroup(of: CloudFile?.self, returning: [CloudFile].self) { group in for file in candidates { - group.addTask { + group.addTask { [metadataCache] in + guard file.gcid == nil else { return file } guard let detail = try? await api.fsDetail(fileID: file.id) else { return file } var copy = file copy.gcid = detail.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]) copy.size = detail.firstInt64Deep(["size", "fileSize", "resSize"]) + await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail) return copy } } @@ -603,7 +727,8 @@ final class AppModel: ObservableObject { defer { isRunningTMDBJob = false } do { let allowed = Set(extensionsText.lowercased().split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }) - let snapshot = try await scanTree(parentID: sourceID, recursive: recursive) + let sourcePath = sourceID == nil ? [] : [tmdbTarget?.name ?? "所选文件夹"] + let snapshot = try await scanTree(parentID: sourceID, recursive: recursive, pathComponents: sourcePath) let minimum = Int64(max(0, minimumSizeMB)) * 1024 * 1024 let candidates = snapshot.allFiles.filter { file in guard !file.isDirectory, (file.size ?? 0) >= minimum else { return false } @@ -663,7 +788,6 @@ final class AppModel: ObservableObject { for index in tmdbJobs.indices where tmdbJobs[index].state == .matched && tmdbJobs[index].isApproved { guard let match = tmdbJobs[index].match else { continue } do { - let year = String(match.releaseDate.prefix(4)) let baseFolder = renderTMDBTemplate(tmdbWorkflow.folderTemplate, match: match, fallback: match.title) // Every season is an independent library root: its own TMDB ID, // NFO, artwork, cast and episodes all live in the same folder. @@ -857,6 +981,7 @@ final class AppModel: ObservableObject { folderPath = [] detail = nil detailFileID = nil + detailErrorMessage = "" scanResult = nil liveScanItems = [] statusMessage = "" @@ -865,10 +990,13 @@ final class AppModel: ObservableObject { private func completeLogin() { persistTokens() isSignedIn = true + isLoadingFiles = true Task { await loadAccount() } } private func loadAccount() async { + isLoadingFiles = true + defer { isLoadingFiles = false } do { try await api.prepareSession() } catch { @@ -876,8 +1004,6 @@ final class AppModel: ObservableObject { return } persistTokens() - isLoadingFiles = true - defer { isLoadingFiles = false } async let userResult: JSONValue? = try? await api.userInfo() async let fileResult: JSONValue? = try? await filesResponse() if let result = await userResult { @@ -886,7 +1012,7 @@ final class AppModel: ObservableObject { statusMessage = "登录状态已恢复" } if let result = await fileResult { - files = extractFiles(from: result) + files = await hydrateFromMetadataCache(extractFiles(from: result)) updatePaging(from: result) // Render the directory response immediately. Per-file detail calls are // expensive and must never block or saturate the homepage on launch. @@ -910,7 +1036,7 @@ final class AppModel: ObservableObject { do { let result = try await task.value guard !Task.isCancelled, activeListRequestID == requestID else { return } - files = extractFiles(from: result) + files = await hydrateFromMetadataCache(extractFiles(from: result)) updatePaging(from: result) // Render the directory response immediately. Per-file detail calls are // expensive and must never block or saturate the homepage on launch. @@ -1023,7 +1149,9 @@ final class AppModel: ObservableObject { detailEnrichmentTask?.cancel() detailEnrichmentGeneration = UUID() let generation = detailEnrichmentGeneration - let page = force ? files : files.filter { $0.size == nil || $0.gcid == nil || $0.modifiedAt.isEmpty } + let page = files.filter { file in + file.isDirectory ? (force || needsMetadata(for: file)) : needsMetadata(for: file) + } guard !page.isEmpty else { isLoadingFolderSizes = false detailLoadingIDs = [] @@ -1042,6 +1170,7 @@ final class AppModel: ObservableObject { private func enrichPageDetails(_ page: [CloudFile], generation: UUID) async { let api = self.api + let metadataCache = self.metadataCache let concurrencyLimit = 6 var iterator = page.makeIterator() await withTaskGroup(of: (String, Int64?, String?, Int64?, Int?, Int?).self) { group in @@ -1049,6 +1178,7 @@ final class AppModel: ObservableObject { guard let file = iterator.next() else { return } group.addTask { let detail = try? await api.fsDetail(fileID: file.id) + if let detail { await metadataCache.save(fileID: file.id, isDirectory: file.isDirectory, detail: detail) } return ( file.id, detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]), @@ -1083,16 +1213,25 @@ final class AppModel: ObservableObject { isLoadingFolderSizes = false } - private func cachedDetail(for fileID: String, forceRefresh: Bool = false) async throws -> JSONValue { + private func cachedDetail(for file: CloudFile, forceRefresh: Bool = false) async throws -> JSONValue { + let fileID = file.id if !forceRefresh, let cached = detailCache[fileID], cached.expiresAt > Date() { return cached.value } + if !forceRefresh, let cached = await metadataCache.metadata(for: fileID), cached.isUsable { + let value = cached.detailValue + detailCache[fileID] = (value, Date().addingTimeInterval(300)) + return value + } if let task = detailRequestTasks[fileID] { return try await task.value } let api = self.api let gate = detailRequestGate + let metadataCache = self.metadataCache let task = Task { await gate.acquire() defer { Task { await gate.release() } } try Task.checkCancellation() - return try await api.fsDetail(fileID: fileID) + let value = try await api.fsDetail(fileID: fileID) + await metadataCache.save(fileID: fileID, isDirectory: file.isDirectory, detail: value) + return value } detailRequestTasks[fileID] = task defer { detailRequestTasks[fileID] = nil } @@ -1102,6 +1241,19 @@ final class AppModel: ObservableObject { return value } + private func hydrateFromMetadataCache(_ source: [CloudFile]) async -> [CloudFile] { + await metadataCache.save(files: source) + let cached = await metadataCache.metadata(for: source.map(\.id)) + return source.map { file in cached[file.id]?.applying(to: file) ?? file } + } + + private func needsMetadata(for file: CloudFile) -> Bool { + if file.isDirectory { + return file.subDirectoryCount == nil || file.subFileCount == nil + } + return file.gcid?.isEmpty != false + } + private static func formatDate(_ epoch: Int64) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") diff --git a/guangya_mac/AppTheme.swift b/guangya_mac/AppTheme.swift new file mode 100644 index 0000000..ba18e6f --- /dev/null +++ b/guangya_mac/AppTheme.swift @@ -0,0 +1,51 @@ +import SwiftUI + +enum AppTheme { + static let orange = Color(red: 1.0, green: 0.45, blue: 0.12) + static let orangeDeep = Color(red: 0.86, green: 0.20, blue: 0.04) +} + +struct AppBackdrop: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + LinearGradient( + colors: colorScheme == .dark + ? [Color(red: 0.07, green: 0.09, blue: 0.10), Color(red: 0.09, green: 0.13, blue: 0.13), Color(red: 0.15, green: 0.10, blue: 0.08)] + : [Color(red: 0.93, green: 0.95, blue: 0.96), Color(red: 0.89, green: 0.94, blue: 0.93), Color(red: 0.97, green: 0.92, blue: 0.87)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .overlay(alignment: .bottom) { + Rectangle() + .fill(colorScheme == .dark ? Color.orange.opacity(0.05) : Color.orange.opacity(0.08)) + .frame(height: 150) + .mask(LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom)) + } + .ignoresSafeArea() + } +} + +private struct SoftCard: ViewModifier { + let requestedRadius: CGFloat + private var radius: CGFloat { min(requestedRadius, 8) } + + @ViewBuilder + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: radius, style: .continuous).stroke(.white.opacity(0.26), lineWidth: 1) } + } else { + content + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: radius, style: .continuous).stroke(.white.opacity(0.30), lineWidth: 1) } + } + } +} + +extension View { + func softCard(radius: CGFloat = 8) -> some View { + modifier(SoftCard(requestedRadius: radius)) + } +} diff --git a/guangya_mac/BatchRenameTool.swift b/guangya_mac/BatchRenameTool.swift new file mode 100644 index 0000000..a7d4f0f --- /dev/null +++ b/guangya_mac/BatchRenameTool.swift @@ -0,0 +1,374 @@ +import Foundation +import SwiftUI + +private enum BatchRenameItemType: String, CaseIterable, Identifiable { + case all = "全部" + case files = "仅文件" + case folders = "仅文件夹" + var id: String { rawValue } +} + +private enum BatchRenameRuleKind: String, CaseIterable, Identifiable { + case remove = "删除字符" + case replace = "查找替换" + case regex = "正则替换" + case prefix = "添加前缀" + case suffix = "添加后缀" + var id: String { rawValue } +} + +private struct BatchRenameRule: Identifiable, Equatable { + let id: UUID + var isEnabled: Bool + var kind: BatchRenameRuleKind + var pattern: String + var replacement: String + var ignoresCase: Bool + + init(id: UUID = UUID(), isEnabled: Bool = true, kind: BatchRenameRuleKind = .remove, pattern: String = "", replacement: String = "", ignoresCase: Bool = false) { + self.id = id + self.isEnabled = isEnabled + self.kind = kind + self.pattern = pattern + self.replacement = replacement + self.ignoresCase = ignoresCase + } + + func applying(to value: String) throws -> String { + guard isEnabled else { return value } + switch kind { + case .remove: + guard !pattern.isEmpty else { return value } + return value.replacingOccurrences(of: pattern, with: "", options: ignoresCase ? [.caseInsensitive] : []) + case .replace: + guard !pattern.isEmpty else { return value } + return value.replacingOccurrences(of: pattern, with: replacement, options: ignoresCase ? [.caseInsensitive] : []) + case .regex: + guard !pattern.isEmpty else { return value } + let expression = try NSRegularExpression(pattern: pattern, options: ignoresCase ? [.caseInsensitive] : []) + return expression.stringByReplacingMatches(in: value, range: NSRange(value.startIndex..., in: value), withTemplate: replacement) + case .prefix: + return pattern + value + case .suffix: + return value + pattern + } + } +} + +private struct BatchRenamePreview: Identifiable { + let file: CloudFile + let newName: String + var error: String? + var id: String { file.id } + var hasChanged: Bool { file.name != newName } +} + +struct BatchRenameToolPage: View { + @ObservedObject var model: AppModel + @State private var sourceID: String? + @State private var sourceName = "云盘根目录" + @State private var usesExplicitSelection = false + @State private var recursive = false + @State private var itemType: BatchRenameItemType = .all + @State private var preserveExtension = true + @State private var filterText = "" + @State private var rules = [BatchRenameRule()] + @State private var candidates: [CloudFile] = [] + @State private var selectedIDs: Set = [] + @State private var isLoading = false + @State private var loadError = "" + @State private var showFolderPicker = false + @State private var showConfirmation = false + + private var previews: [BatchRenamePreview] { + var values = candidates.filter { file in + let matchesType = itemType == .all || (itemType == .files && !file.isDirectory) || (itemType == .folders && file.isDirectory) + let matchesFilter = filterText.isEmpty || file.name.localizedCaseInsensitiveContains(filterText) + return matchesType && matchesFilter + }.map(makePreview) + + let proposedGroups = Dictionary(grouping: values.filter { $0.hasChanged && $0.error == nil }) { + destinationKey(path: $0.file.cloudPath, name: $0.newName) + } + let duplicateKeys = Set(proposedGroups.filter { $0.value.count > 1 }.map(\.key)) + let existing = Dictionary(grouping: candidates) { destinationKey(path: $0.cloudPath, name: $0.name) } + + for index in values.indices where values[index].hasChanged && values[index].error == nil { + let key = destinationKey(path: values[index].file.cloudPath, name: values[index].newName) + if duplicateKeys.contains(key) { + values[index].error = "规则产生了同名项目" + } else if existing[key]?.contains(where: { $0.id != values[index].file.id }) == true { + values[index].error = "目标名称已存在" + } + } + return values + } + + private var applicablePreviews: [BatchRenamePreview] { + previews.filter { $0.hasChanged && $0.error == nil } + } + + private var selectedChanges: [BatchRenameChange] { + applicablePreviews.filter { selectedIDs.contains($0.id) }.map { BatchRenameChange(file: $0.file, newName: $0.newName) } + } + + var body: some View { + ScrollView { + VStack(spacing: 14) { + sourceSection + rulesSection + previewSection + } + .padding(.bottom, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheet(isPresented: $showFolderPicker) { + ScanFolderPickerSheet(model: model, title: "选择重命名目录", selectionLabel: "选择此目录") { id, name in + model.batchRenameSelection = [] + model.batchRenameTarget = nil + usesExplicitSelection = false + sourceID = id + sourceName = name + candidates = [] + selectedIDs = [] + } + } + .confirmationDialog("确认批量重命名?", isPresented: $showConfirmation) { + Button("应用 \(selectedChanges.count) 项重命名") { + let changes = selectedChanges + Task { + _ = await model.applyBatchRenames(changes) + if usesExplicitSelection { + model.batchRenameSelection = [] + candidates = [] + selectedIDs = [] + sourceName = "已完成所选项目" + } else { + await loadCandidates() + } + } + } + Button("取消", role: .cancel) { } + } message: { + Text("名称修改会直接同步到云盘。执行前请再次核对预览。") + } + .task { + syncTarget() + if !usesExplicitSelection { await loadCandidates() } + } + .onChange(of: model.batchRenameTarget?.id) { _, _ in + syncTarget() + if !usesExplicitSelection { Task { await loadCandidates() } } + } + .onChange(of: model.batchRenameSelection.map(\.id)) { _, _ in + syncTarget() + } + } + + private var sourceSection: some View { + ToolPageCard(icon: "folder.badge.gearshape", tint: .orange, title: "数据源", detail: sourceName) { + HStack(spacing: 10) { + Picker("类型", selection: $itemType) { + ForEach(BatchRenameItemType.allCases) { Text($0.rawValue).tag($0) } + } + .frame(width: 150) + TextField("结果内过滤", text: $filterText).textFieldStyle(.roundedBorder) + Toggle("保留扩展名", isOn: $preserveExtension).fixedSize() + Toggle("包含子文件夹", isOn: $recursive).fixedSize().disabled(usesExplicitSelection) + Button { showFolderPicker = true } label: { Label("选择目录", systemImage: "folder") } + .buttonStyle(.bordered) + Button { + if usesExplicitSelection { syncTarget() } + else { Task { await loadCandidates() } } + } label: { + if isLoading { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") } + } + .buttonStyle(.bordered) + .help("重新读取目录") + .disabled(isLoading || model.isBatchRenaming) + } + if !loadError.isEmpty { + Label(loadError, systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.red) + } + } + } + + private var rulesSection: some View { + ToolPageCard(icon: "list.number", tint: .blue, title: "规则链", detail: "按从上到下的顺序应用 \(rules.count) 条规则") { + VStack(spacing: 8) { + ForEach(Array(rules.enumerated()), id: \.element.id) { index, rule in + ruleRow(index: index, rule: rule) + } + } + HStack { + Button { rules.append(BatchRenameRule(kind: .replace)) } label: { Label("添加规则", systemImage: "plus") } + .buttonStyle(.bordered) + Spacer() + Button("清空规则") { rules = [BatchRenameRule()] }.disabled(rules.count == 1 && rules[0].pattern.isEmpty && rules[0].isEnabled) + } + } + } + + private var previewSection: some View { + ToolPageCard(icon: "rectangle.and.pencil.and.ellipsis", tint: .green, title: "预览", detail: previewSummary) { + if isLoading { + VStack(spacing: 12) { ProgressView(); Text("正在读取目录内容…").foregroundStyle(.secondary) } + .frame(maxWidth: .infinity).padding(.vertical, 42) + } else if candidates.isEmpty { + ContentUnavailableView("目录中没有项目", systemImage: "folder", description: Text("选择目录或重新读取数据源。")) + .frame(minHeight: 180) + } else { + HStack { + Button("全选可应用项") { selectedIDs = Set(applicablePreviews.map(\.id)) }.buttonStyle(.bordered) + Button("全不选") { selectedIDs.removeAll() }.buttonStyle(.bordered) + Spacer() + Text("已选 \(selectedChanges.count) 项").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + Button { + showConfirmation = true + } label: { + HStack(spacing: 7) { + if model.isBatchRenaming { ProgressView().controlSize(.small) } + Label(model.isBatchRenaming ? "正在应用 \(model.batchRenameProgress.completed)/\(model.batchRenameProgress.total)" : "应用重命名", systemImage: "play.fill") + } + } + .buttonStyle(.borderedProminent).tint(.orange) + .disabled(selectedChanges.isEmpty || model.isBatchRenaming) + } + Divider() + LazyVStack(spacing: 0) { + ForEach(previews) { preview in previewRow(preview) } + } + } + } + } + + private var previewSummary: String { + let invalid = previews.filter { $0.error != nil }.count + return "共 \(previews.count) 项,可重命名 \(applicablePreviews.count) 项" + (invalid > 0 ? ",\(invalid) 项冲突" : "") + } + + private func ruleRow(index: Int, rule: BatchRenameRule) -> some View { + HStack(spacing: 8) { + Toggle("启用", isOn: $rules[index].isEnabled).labelsHidden().help("启用或停用此规则") + Picker("规则", selection: $rules[index].kind) { + ForEach(BatchRenameRuleKind.allCases) { Text($0.rawValue).tag($0) } + } + .labelsHidden().frame(width: 135) + TextField(patternPlaceholder(for: rule.kind), text: $rules[index].pattern).textFieldStyle(.roundedBorder) + if rule.kind == .replace || rule.kind == .regex { + Image(systemName: "arrow.right").foregroundStyle(.tertiary) + TextField("替换为", text: $rules[index].replacement).textFieldStyle(.roundedBorder) + } + if rule.kind == .remove || rule.kind == .replace || rule.kind == .regex { + Toggle("忽略大小写", isOn: $rules[index].ignoresCase).fixedSize() + } + Button { moveRule(index, offset: -1) } label: { Image(systemName: "arrow.up") } + .buttonStyle(.plain).disabled(index == 0).help("上移") + Button { moveRule(index, offset: 1) } label: { Image(systemName: "arrow.down") } + .buttonStyle(.plain).disabled(index == rules.count - 1).help("下移") + Button(role: .destructive) { removeRule(index) } label: { Image(systemName: "trash") } + .buttonStyle(.plain).help("删除规则") + } + .padding(10) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func previewRow(_ preview: BatchRenamePreview) -> some View { + let canSelect = preview.hasChanged && preview.error == nil + return HStack(spacing: 10) { + Toggle("", isOn: Binding( + get: { selectedIDs.contains(preview.id) }, + set: { if $0 && canSelect { selectedIDs.insert(preview.id) } else { selectedIDs.remove(preview.id) } } + )) + .labelsHidden().disabled(!canSelect) + Image(systemName: preview.file.icon).foregroundStyle(preview.file.isDirectory ? .orange : .accentColor).frame(width: 22) + VStack(alignment: .leading, spacing: 3) { + Text(preview.file.name).font(.caption).foregroundStyle(.secondary).lineLimit(1) + Text(preview.newName).font(.callout.weight(.medium)).lineLimit(1) + } + Spacer() + if let error = preview.error { + Label(error, systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.red) + } else if preview.hasChanged { + Text("将修改").font(.caption.weight(.semibold)).foregroundStyle(.green) + } else { + Text("无变化").font(.caption).foregroundStyle(.tertiary) + } + } + .padding(.vertical, 8) + .overlay(alignment: .bottom) { Divider() } + } + + private func makePreview(_ file: CloudFile) -> BatchRenamePreview { + let extensionText = (!file.isDirectory && preserveExtension) ? (file.name as NSString).pathExtension : "" + let originalBase = extensionText.isEmpty ? file.name : (file.name as NSString).deletingPathExtension + do { + let renamedBase = try rules.reduce(originalBase) { try $1.applying(to: $0) } + let newName = extensionText.isEmpty ? renamedBase : renamedBase + "." + extensionText + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return BatchRenamePreview(file: file, newName: newName, error: "名称不能为空") } + if newName.contains("/") || newName.contains("\0") { return BatchRenamePreview(file: file, newName: newName, error: "名称包含非法字符") } + return BatchRenamePreview(file: file, newName: newName, error: nil) + } catch { + return BatchRenamePreview(file: file, newName: file.name, error: "正则表达式无效") + } + } + + private func destinationKey(path: String, name: String) -> String { + let parent = (path as NSString).deletingLastPathComponent + return parent.lowercased() + "/" + name.lowercased() + } + + private func patternPlaceholder(for kind: BatchRenameRuleKind) -> String { + switch kind { + case .remove: return "要删除的字符" + case .replace: return "查找内容" + case .regex: return "正则表达式,例如 \\s+" + case .prefix: return "前缀" + case .suffix: return "后缀" + } + } + + private func moveRule(_ index: Int, offset: Int) { + let destination = index + offset + guard rules.indices.contains(index), rules.indices.contains(destination) else { return } + rules.swapAt(index, destination) + } + + private func removeRule(_ index: Int) { + guard rules.indices.contains(index) else { return } + rules.remove(at: index) + if rules.isEmpty { rules = [BatchRenameRule()] } + } + + private func syncTarget() { + if !model.batchRenameSelection.isEmpty { + usesExplicitSelection = true + candidates = model.batchRenameSelection + selectedIDs = Set(candidates.map(\.id)) + sourceID = nil + sourceName = "已选择 \(candidates.count) 项" + } else if let target = model.batchRenameTarget, target.isDirectory { + usesExplicitSelection = false + sourceID = target.id + sourceName = target.name + } else { + usesExplicitSelection = false + sourceID = model.currentScanRootID + sourceName = model.scanScopeLocationName + } + } + + @MainActor + private func loadCandidates() async { + guard !isLoading, !usesExplicitSelection else { return } + isLoading = true + loadError = "" + defer { isLoading = false } + do { + candidates = try await model.batchRenameCandidates(parentID: sourceID, rootName: sourceName, recursive: recursive) + selectedIDs = Set(candidates.map(\.id)) + } catch is CancellationError { } + catch { loadError = error.localizedDescription } + } +} diff --git a/guangya_mac/ContentView.swift b/guangya_mac/ContentView.swift index e42ce79..0acc473 100644 --- a/guangya_mac/ContentView.swift +++ b/guangya_mac/ContentView.swift @@ -24,60 +24,12 @@ struct ContentView: View { } } -private enum AppTheme { - static let orange = Color(red: 1.0, green: 0.45, blue: 0.12) - static let orangeDeep = Color(red: 0.86, green: 0.20, blue: 0.04) -} - -private struct AppBackdrop: View { - var body: some View { - ZStack { - LinearGradient( - colors: [Color(nsColor: .windowBackgroundColor), Color.orange.opacity(0.08), Color(nsColor: .underPageBackgroundColor)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - RadialGradient(colors: [Color.orange.opacity(0.20), .clear], center: .topLeading, startRadius: 40, endRadius: 520) - RadialGradient(colors: [Color.pink.opacity(0.10), .clear], center: .bottomTrailing, startRadius: 60, endRadius: 560) - } - .ignoresSafeArea() - } -} - -private struct SoftCard: ViewModifier { - let radius: CGFloat - func body(content: Content) -> some View { - content - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: radius, style: .continuous) - .stroke(.white.opacity(0.46), lineWidth: 1) - } - .shadow(color: .black.opacity(0.10), radius: 24, x: 0, y: 14) - } -} - -private extension View { - func softCard(radius: CGFloat = 22) -> some View { modifier(SoftCard(radius: radius)) } -} - -private struct LiquidGlassModifier: ViewModifier { - let cornerRadius: CGFloat - - @ViewBuilder - func body(content: Content) -> some View { - if #available(macOS 26.0, *) { - content.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - } else { - content.background(.regularMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - } - } -} - -private extension View { - func liquidGlass(cornerRadius: CGFloat = 16) -> some View { - modifier(LiquidGlassModifier(cornerRadius: cornerRadius)) - } +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 { @@ -358,107 +310,7 @@ private struct LoginFieldLabel: View { } } -private enum WorkspaceTool: String, CaseIterable, Identifiable { - case scan = "文件扫描与清理" - case tmdb = "TMDB 整理" - case settings = "工作区设置" - - var id: String { rawValue } - var icon: String { - switch self { - case .scan: return "magnifyingglass.circle.fill" - case .tmdb: return "film.stack" - case .settings: return "gearshape" - } - } - var subtitle: String { - switch self { - case .scan: return "空文件夹、重复文件与相似文件夹统一扫描" - case .tmdb: return "配置并执行媒体识别整理" - case .settings: return "代理和本机工具配置" - } - } -} - -private struct WorkspaceView: View { - @ObservedObject var model: AppModel - @State private var selectedFileIDs: Set = [] - @State private var renameFile: CloudFile? - @State private var isCreatingFolder = false - @State private var isImporting = false - @State private var isShowingSettings = false - @State private var isShowingDuplicates = false - @State private var isShowingTools = false - @State private var selectedTool: WorkspaceTool? - @State private var folderName = "" - @State private var columnVisibility: NavigationSplitViewVisibility = .all - @State private var inspectorFile: CloudFile? - @State private var previewFile: CloudFile? - @State private var globalSearchText = "" - - private var selectedFile: CloudFile? { selectedFileIDs.count == 1 ? model.files.first { $0.id == selectedFileIDs.first } : nil } - - var body: some View { - ZStack { - AppBackdrop() - NavigationSplitView(columnVisibility: $columnVisibility) { - WorkspaceSidebar(model: model, selectedTool: $selectedTool, columnVisibility: $columnVisibility) - .navigationSplitViewColumnWidth(min: 210, ideal: 238, max: 280) - } detail: { - if let selectedTool { - ToolWorkspacePage(model: model, tool: selectedTool) - } else { - FilesBrowser(model: model, selectedFileIDs: $selectedFileIDs, globalSearchText: $globalSearchText, renameFile: $renameFile, isCreatingFolder: $isCreatingFolder, isImporting: $isImporting, isShowingSettings: $isShowingSettings, isShowingDuplicates: $isShowingDuplicates, folderName: $folderName, onShowDetails: { file in inspectorFile = file }, onPreview: { file in previewFile = file }) - } - } - .scrollContentBackground(.hidden) - .toolbar { ToolbarItem(placement: .principal) { AppTitleToolbar(model: model, searchText: $globalSearchText) } } - } - .frame(minWidth: 1240, minHeight: 740) - .sheet(item: $renameFile) { file in RenameSheet(file: file) { name in Task { await model.rename(file, to: name) } } } - .sheet(isPresented: $isCreatingFolder) { - CreateFolderSheet(name: $folderName) { name in - isCreatingFolder = false - folderName = "" - Task { await model.createFolder(name: name) } - } onCancel: { isCreatingFolder = false } - } - .sheet(isPresented: $isShowingSettings) { WorkspaceSettings(model: model) } - .sheet(isPresented: $isShowingDuplicates) { DuplicateResults(model: model) } - .sheet(isPresented: $isShowingTools) { ToolsPanel(model: model, isShowingSettings: $isShowingSettings, isShowingDuplicates: $isShowingDuplicates) } - .sheet(item: $inspectorFile) { file in FileDetailsSheet(model: model, file: file) } - .sheet(item: $previewFile) { file in FilePreviewSheet(model: model, file: file) } - .fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in - if case .success(let urls) = result, let url = urls.first { Task { await model.upload(url: url) } } - } - .alert("提示", isPresented: Binding(get: { !model.lastActionMessage.isEmpty }, set: { if !$0 { model.lastActionMessage = "" } })) { - Button("确定", role: .cancel) { model.lastActionMessage = "" } - } message: { Text(model.lastActionMessage) } - .task(id: model.section) { - selectedFileIDs = [] - selectedTool = nil - await model.refresh() - } - } -} - -private struct AppTitleToolbar: View { - @ObservedObject var model: AppModel - @Binding var searchText: String - @State private var scope = "当前" - private var supportsSearch: Bool { [.files, .photos, .videos, .audio, .documents].contains(model.section) } - var body: some View { - HStack(spacing: 12) { - Label(model.section.rawValue, systemImage: model.section.icon).font(.headline.weight(.bold)) - if supportsSearch { - Picker("范围", selection: $scope) { Text("当前").tag("当前"); Text("全盘").tag("全盘") }.labelsHidden().pickerStyle(.segmented).frame(width: 112) - TextField(scope == "全盘" ? "全盘搜索" : "搜索当前目录", text: $searchText).textFieldStyle(.roundedBorder).frame(width: 260) - } - } - } -} - -private struct WorkspaceSidebar: View { +struct WorkspaceSidebar: View { @ObservedObject var model: AppModel @Binding var selectedTool: WorkspaceTool? @Binding var columnVisibility: NavigationSplitViewVisibility @@ -516,7 +368,13 @@ private struct WorkspaceSidebar: View { if toolsExpanded { VStack(spacing: 4) { ForEach(WorkspaceTool.allCases) { tool in - ToolSidebarItem(tool: tool, selected: selectedTool == tool) { selectedTool = tool } + ToolSidebarItem(tool: tool, selected: selectedTool == tool) { + if tool == .rename { + model.batchRenameTarget = nil + model.batchRenameSelection = [] + } + selectedTool = tool + } } } .transition(.opacity.combined(with: .move(edge: .top))) @@ -613,7 +471,7 @@ private struct ToolSidebarItem: View { } } -private struct FilesBrowser: View { +struct FilesBrowser: View { @ObservedObject var model: AppModel @Binding var selectedFileIDs: Set @Binding var globalSearchText: String @@ -622,21 +480,18 @@ private struct FilesBrowser: View { @Binding var isImporting: Bool @Binding var isShowingSettings: Bool @Binding var isShowingDuplicates: Bool - @Binding var folderName: String - let onShowDetails: (CloudFile) -> Void let onPreview: (CloudFile) -> Void - @State private var searchText = "" + let onOpenTool: (WorkspaceTool) -> Void + let onShowInspector: () -> Void @State private var pendingDelete: CloudFile? @State private var showBatchDeleteConfirmation = false @State private var selectionAnchorID: String? @State private var keyboardFocusID: String? - @State private var searchScope = "当前" @State private var tmdbTarget: CloudFile? // Server already returns this page in requested order; keep client filtering order intact. private var filteredFiles: [CloudFile] { - let query = globalSearchText.isEmpty ? searchText : globalSearchText - return query.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(query) } + globalSearchText.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(globalSearchText) } } private var fileCount: Int { model.files.filter { !$0.isDirectory }.count } private var folderCount: Int { model.files.filter(\.isDirectory).count } @@ -691,11 +546,13 @@ private struct FilesBrowser: View { } private var browserHeader: some View { - VStack(spacing: 9) { - HStack { breadcrumb; Spacer(minLength: 0) } + HStack(spacing: 12) { + breadcrumb + Spacer(minLength: 12) + browserActions } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 4).padding(.vertical, 2) + .padding(.horizontal, 4).padding(.vertical, 4) } private var breadcrumb: some View { @@ -728,21 +585,12 @@ private struct FilesBrowser: View { private var statusBar: some View { HStack(spacing: 12) { - Label("文件 \(fileCount)", systemImage: "doc.fill").foregroundStyle(.secondary) - Label("文件夹 \(folderCount)", systemImage: "folder.fill").foregroundStyle(.secondary) - Label("当前页 \(totalSizeText)", systemImage: "externaldrive.fill").foregroundStyle(.secondary) + Label("本页文件 \(fileCount)", systemImage: "doc.fill").foregroundStyle(.secondary) + Label("本页文件夹 \(folderCount)", systemImage: "folder.fill").foregroundStyle(.secondary) + Label("本页大小 \(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 - if model.section == .files { - HStack(spacing: 7) { - if !selectedFileIDs.isEmpty { Button(role: .destructive) { showBatchDeleteConfirmation = true } label: { Label("删除已选 \(selectedFileIDs.count)", systemImage: "trash") }.buttonStyle(.bordered).help("删除选中的 \(selectedFileIDs.count) 项") } - 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 { Task { await model.refresh() } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.bordered).help(model.isLoadingFiles ? "中断当前请求并重新加载" : "刷新").accessibilityLabel("刷新文件列表").disabled(model.isBusy) - moreMenu - } - } } .font(.caption) .padding(.horizontal, 12).padding(.vertical, 8) @@ -750,10 +598,35 @@ private struct FilesBrowser: View { .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 { + 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 { 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 model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section).softCard(radius: 24).padding(.horizontal, 10) } - else if model.isLoadingFiles && model.files.isEmpty { LoadingFilesView().softCard(radius: 24).padding(.horizontal, 10) } + else if model.isLoadingFiles { LoadingFilesView().softCard(radius: 8).padding(.horizontal, 10) } else if filteredFiles.isEmpty { EmptyFilesView(model: model).softCard(radius: 24).padding(.horizontal, 10) } else { List(selection: $selectedFileIDs) { @@ -790,8 +663,7 @@ private struct FilesBrowser: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(.white.opacity(0.42), lineWidth: 1) } + .softCard(radius: 24) } } } @@ -844,7 +716,7 @@ private struct FilesBrowser: View { 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("2000 / 页").tag(2000); Text("5000 / 页").tag(5000); Text("10000 / 页").tag(10000) + Text("50 / 页").tag(50); Text("100 / 页").tag(100); Text("200 / 页").tag(200); Text("500 / 页").tag(500); Text("1000 / 页").tag(1000) } .labelsHidden().frame(width: 92) Button { Task { await model.goToPage(model.currentPage - 1) } } label: { Image(systemName: "chevron.left") } @@ -875,7 +747,10 @@ private struct FilesBrowser: 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.fileType == 2 { Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("使用 IINA / VLC 播放", systemImage: "play.rectangle.on.rectangle") } } - Button { selectedFileIDs = [file.id]; onShowDetails(file) } label: { Label("查看详情", systemImage: "info.circle") } + 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) } } @@ -889,12 +764,17 @@ private struct FilesBrowser: View { Button { renameFile = file } label: { Label("重命名", systemImage: "pencil") } if file.isDirectory { Menu("文件夹工具") { - Button { model.startScan(ScanRequest(kind: .emptyFolders, scope: .recursiveCurrentFolder, rootID: file.id, rootName: file.name, excludeFilesSmallerThan: nil)) } label: { Label("扫描空文件夹", systemImage: "folder.badge.minus") } - Button { model.startScan(ScanRequest(kind: .duplicates, scope: .recursiveCurrentFolder, rootID: file.id, rootName: file.name, excludeFilesSmallerThan: nil)) } label: { Label("扫描重复文件", systemImage: "square.on.square") } - Button { model.startScan(ScanRequest(kind: .similarFolders, scope: .recursiveCurrentFolder, rootID: file.id, rootName: file.name, excludeFilesSmallerThan: nil)) } label: { Label("扫描相似文件夹", systemImage: "rectangle.3.group") } + 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") } } - Button { tmdbTarget = file } label: { Label("TMDB 识别与刮削…", systemImage: "film") } + Button { + model.tmdbTarget = file + onOpenTool(.tmdb) + } label: { Label("TMDB 识别与刮削…", systemImage: "film") } } else { + Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") } Button { tmdbTarget = file } label: { Label("TMDB 识别与刮削…", systemImage: "film") } } Button { model.copyTransferJSON(for: file) } label: { Label("复制秒传 JSON", systemImage: "doc.on.doc") } @@ -902,20 +782,28 @@ private struct FilesBrowser: View { if model.section == .recycle { Button { Task { await model.restore(file) } } label: { Label("恢复", systemImage: "arrow.uturn.backward") } } Button(role: .destructive) { pendingDelete = file } label: { Label("删除…", systemImage: "trash") } } -} -private struct WorkspaceStat: View { - let title: String - let value: String - let icon: String - let color: Color - var body: some View { - HStack(spacing: 10) { - Image(systemName: icon).foregroundStyle(color).frame(width: 28, height: 28).background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) - VStack(alignment: .leading, spacing: 1) { Text(value).font(.callout.weight(.bold)); Text(title).font(.caption2).foregroundStyle(.secondary) } + 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 } - .padding(.horizontal, 12).padding(.vertical, 10) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + onOpenTool(.rename) } } @@ -1019,6 +907,9 @@ 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) { @@ -1027,7 +918,7 @@ private struct FileDetailsSheet: View { .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 model.isBusy { ProgressView().controlSize(.small) } + if isLoading { ProgressView().controlSize(.small) } Button("完成") { dismiss() }.buttonStyle(.borderedProminent).tint(.orange) }.padding(20) Divider() @@ -1039,23 +930,34 @@ private struct FileDetailsSheet: View { InspectorField(title: "大小", value: file.formattedSize) InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt) } - if model.detailFileID == file.id, let detail = model.detail { + 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 !model.isBusy { - ContentUnavailableView("暂无详情", systemImage: "info.circle", description: Text("未能读取服务器详情")) + } 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 model.showDetails(file) } + .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 } } } -private struct FilePreviewSheet: View { +struct FilePreviewSheet: View { @ObservedObject var model: AppModel let file: CloudFile @Environment(\.dismiss) private var dismiss @@ -1126,9 +1028,12 @@ private struct WebPreview: NSViewRepresentable { func updateNSView(_ view: WKWebView, context: Context) { if view.url != url { view.load(URLRequest(url: url)) } } } -private struct FileInspector: View { +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) { @@ -1142,13 +1047,24 @@ private struct FileInspector: View { .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 { Task { await model.open(file) } } label: { Label(file.isDirectory ? "打开" : "下载", systemImage: file.isDirectory ? "folder" : "arrow.down.circle") }.buttonStyle(.borderedProminent).tint(.orange) + 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() @@ -1162,35 +1078,57 @@ private struct FileInspector: View { } InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt) } - if model.detailFileID == file.id, let detail = model.detail { + 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() - HStack { Text("接口详情").font(.caption.weight(.bold)); Spacer(); Image(systemName: "curlybraces").foregroundStyle(.tertiary) } - ScrollView { Text(jsonText(detail)).font(.system(.caption, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) }.frame(maxHeight: 240) + 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(spacing: 14) { - Image(systemName: "sidebar.right").font(.system(size: 38, weight: .light)).foregroundStyle(.tertiary) - Text("选择一个文件").font(.headline) - Text("单击文件会自动读取详情;双击/打开文件夹继续浏览。").font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) - }.frame(maxWidth: .infinity, maxHeight: .infinity) + 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) - .softCard(radius: 24) - .padding(10) - .onChange(of: file?.id) { _, _ in model.detail = nil; model.detailFileID = nil } + .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: 12) { ProgressView(); Text("正在加载文件…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } + 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)) } } -private struct ToolWorkspacePage: View { +struct ToolWorkspacePage: View { @ObservedObject var model: AppModel let tool: WorkspaceTool @@ -1205,6 +1143,7 @@ private struct ToolWorkspacePage: View { switch tool { case .scan: UnifiedScanToolPage(model: model) + case .rename: BatchRenameToolPage(model: model) case .tmdb: TMDBToolPage(model: model) case .settings: ToolSettingsPage(model: model) } @@ -1224,30 +1163,191 @@ private struct UnifiedScanToolPage: View { 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; Task { await model.loadScanPickerFolders() } }.buttonStyle(.bordered); Button("全盘扫描") { selectedRootID = nil; selectedRootName = "整个云盘" }.buttonStyle(.borderedProminent).tint(.orange) } + 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") } - if model.isScanning { HStack { ProgressView(); Text("\(model.scanProgress.phase) · 文件夹 \(model.scanProgress.foldersVisited) · 文件 \(model.scanProgress.filesVisited)").font(.caption).foregroundStyle(.secondary); Spacer(); Button("取消") { model.cancelScan() }.buttonStyle(.bordered) } } } - if model.activeScanRequest != nil || model.scanResult != nil { ScanToolPage(model: model, kind: model.activeScanRequest?.kind ?? model.scanResult!.request.kind) } + 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) + } } - .sheet(isPresented: $showFolderPicker) { VStack(alignment: .leading, spacing: 14) { HStack { Text("选择扫描文件夹").font(.title3.weight(.bold)); Spacer(); Button("整个云盘") { selectedRootID = nil; selectedRootName = "整个云盘"; showFolderPicker = false }.buttonStyle(.bordered) }; if model.isLoadingScanPicker { ProgressView("正在读取云盘根目录…").frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(model.scanPickerFolders) { folder in Button { selectedRootID = folder.id; selectedRootName = folder.name; showFolderPicker = false } label: { Label(folder.name, systemImage: "folder.fill") }.buttonStyle(.plain) }.listStyle(.plain) } }.padding(20).frame(minWidth: 520, minHeight: 520) } + .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 scanButton(_ kind: ScanKind, _ title: String, _ icon: String) -> some View { - Button { model.startScan(ScanRequest(kind: kind, scope: selectedRootID == nil ? .wholeDrive : .recursiveCurrentFolder, rootID: selectedRootID, rootName: selectedRootName, excludeFilesSmallerThan: nil)) } label: { Label(title, systemImage: icon) }.buttonStyle(.bordered).disabled(model.isScanning) + 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 { + Button { model.startScan(ScanRequest(kind: kind, scope: selectedRootID == nil ? .wholeDrive : .recursiveCurrentFolder, rootID: selectedRootID, rootName: selectedRootName, excludeFilesSmallerThan: nil)) } 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 + } + } +} + +struct ScanFolderPickerSheet: View { + @ObservedObject var model: AppModel + var title = "选择扫描文件夹" + var selectionLabel = "选择此文件夹" + let onSelect: (String?, String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var path: [FolderPath] = [] + + private var currentID: String? { path.last?.id } + private var currentName: String { path.isEmpty ? "整个云盘" : path.map(\.name).joined(separator: " / ") } + + 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 + + Group { + if model.isLoadingScanPicker { + VStack(spacing: 12) { ProgressView(); Text("正在读取文件夹…").foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.scanPickerFolders.isEmpty { + ContentUnavailableView("没有子文件夹", systemImage: "folder", description: Text("可以选择当前文件夹作为扫描范围。")) + } else { + List(model.scanPickerFolders) { 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)) + Task { await model.loadScanPickerFolders(parentID: folder.id) } + } + + private func navigateToRoot() { + guard !model.isLoadingScanPicker else { return } + path = [] + 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 showDeletionConfirmation = false - @State private var showFolderPicker = false @State private var detailFolder: CloudFile? private var resultItems: [ScanItem] { @@ -1255,60 +1355,158 @@ private struct ScanToolPage: View { return model.scanResult?.request.kind == kind ? model.scanResult?.items ?? [] : [] } - var body: some View { - VStack(spacing: 16) { - ToolPageCard(icon: kind.icon, tint: kind == .emptyFolders ? .orange : (kind == .duplicates ? .blue : .green), title: "扫描配置", detail: "扫描不会修改任何云端文件;请在结果中复核后再执行清理。") { - Picker("扫描范围", selection: $scope) { ForEach(ScanScope.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) - if scope != .wholeDrive { - HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text("扫描起点").font(.caption).foregroundStyle(.secondary) - Text(selectedRootName).font(.callout.weight(.semibold)).lineLimit(1) - } - Spacer() - Button("使用当前目录") { selectedRootID = model.currentScanRootID; selectedRootName = model.scanScopeLocationName }.buttonStyle(.bordered) - Button("浏览云盘…") { showFolderPicker = true; Task { await model.loadScanPickerFolders() } }.buttonStyle(.bordered) - } - .padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) - } - if kind == .duplicates { Toggle("排除小于 1 MB 的文件", isOn: $excludeSmall) } - HStack { - Button { model.startScan(ScanRequest(kind: kind, scope: scope, rootID: scope == .wholeDrive ? nil : selectedRootID, rootName: scope == .wholeDrive ? "整个云盘" : selectedRootName, excludeFilesSmallerThan: excludeSmall ? 1024 * 1024 : nil)) } label: { Label("开始扫描", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).disabled(model.isScanning) - if model.isScanning { ProgressView(); Text("\(model.scanProgress.phase) · 文件夹 \(model.scanProgress.foldersVisited) · 文件 \(model.scanProgress.filesVisited)").font(.caption).foregroundStyle(.secondary); Button("取消") { model.cancelScan() }.buttonStyle(.bordered) } - } - } - ToolPageCard(icon: "list.bullet.rectangle", tint: .purple, title: "扫描结果", detail: resultItems.isEmpty ? "尚未发现结果,或尚未开始扫描。" : "发现 \(resultItems.count) 组/项结果,请逐项复核。") { - if resultItems.isEmpty { EmptyState(icon: kind.icon, title: model.isScanning ? "正在扫描" : "等待扫描", subtitle: model.isScanning ? "结果会在发现后立即显示。" : "选择范围后开始只读扫描。") } - else { - if kind == .emptyFolders { Toggle("全选空文件夹", isOn: Binding(get: { selectedIDs.count == resultItems.count }, set: { selectedIDs = $0 ? Set(resultItems.map(\.id)) : [] })) } - else { Text("每组默认保留第一项;勾选其余项后才可删除。请先逐项复核。").font(.caption).foregroundStyle(.secondary) } - ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(resultItems) { item in scanResultRow(item) } }.padding(.vertical, 2) } - .frame(maxHeight: .infinity) - Button(role: .destructive) { showDeletionConfirmation = true } label: { Label(kind == .emptyFolders ? "删除已选 \(selectedIDs.count) 个空文件夹" : "删除已选 \(selectedIDs.count) 项", systemImage: "trash") }.buttonStyle(.borderedProminent).disabled(selectedIDs.isEmpty) - } - } - .frame(maxHeight: .infinity) + 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 { Toggle("全选空文件夹", isOn: Binding(get: { selectedIDs.count == resultItems.count }, set: { selectedIDs = $0 ? Set(resultItems.map(\.id)) : [] })) } + else if kind == .duplicates { + HStack { + Text("勾选项将被删除;每组始终至少保留一个文件。").font(.caption).foregroundStyle(.secondary) + Spacer() + Text("已选 \(selectedIDs.count) 项 · \(selectedDuplicateSizeText)").font(.caption.weight(.semibold)).monospacedDigit().foregroundStyle(.blue) + duplicateQuickSelectionMenu + } + } else { Text("每组默认保留第一项;勾选其余项后才可删除。请先逐项复核。").font(.caption).foregroundStyle(.secondary) } + 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 ? "删除已选 \(selectedIDs.count) 个空文件夹" : "删除已选 \(selectedIDs.count) 项"), systemImage: "trash") + } + }.buttonStyle(.borderedProminent).disabled(selectedIDs.isEmpty || model.isScanning || model.isDeletingScanResults) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .confirmationDialog(kind == .emptyFolders ? "确认删除已选空文件夹?" : "确认删除已选项目?", isPresented: $showDeletionConfirmation) { Button(kind == .emptyFolders ? "删除 \(selectedIDs.count) 个文件夹" : "删除 \(selectedIDs.count) 项", role: .destructive) { let ids = selectedFileIDsForDeletion Task { if kind == .emptyFolders { await model.deleteScannedEmptyFolders(ids) } else { await model.deleteScannedFiles(ids) } + selectedIDs.subtract(ids) } - selectedIDs = [] } } message: { Text(kind == .emptyFolders ? "执行前会逐个重新验证为空,发生变化的目录会被跳过。" : "此操作不可撤销。请确认每个分组至少保留一项。") } .sheet(item: $detailFolder) { FileDetailsSheet(model: model, file: $0) } - .sheet(isPresented: $showFolderPicker) { - VStack(alignment: .leading, spacing: 14) { - HStack { VStack(alignment: .leading) { Text("选择扫描文件夹").font(.title3.weight(.bold)); Text("云盘根目录").font(.caption).foregroundStyle(.secondary) }; Spacer(); Button("整个云盘") { selectedRootID = nil; selectedRootName = "整个云盘"; scope = .wholeDrive; showFolderPicker = false }.buttonStyle(.bordered) } - Divider() - if model.isLoadingScanPicker { ProgressView("正在读取根目录…").frame(maxWidth: .infinity, maxHeight: .infinity) } - else if model.scanPickerFolders.isEmpty { ContentUnavailableView("根目录没有文件夹", systemImage: "folder") } - else { List(model.scanPickerFolders) { folder in Button { selectedRootID = folder.id; selectedRootName = folder.name; scope = .customFolder; showFolderPicker = false } label: { Label(folder.name, systemImage: "folder.fill") }.buttonStyle(.plain) }.listStyle(.plain) } - }.padding(20).frame(minWidth: 520, minHeight: 520) + } + + 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 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() + Button("清除快速选择") { clearDuplicateSelection() } + } 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 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 var selectedFileIDsForDeletion: Set { @@ -1332,11 +1530,24 @@ private struct ScanToolPage: View { } case .duplicate(let group): VStack(alignment: .leading, spacing: 8) { - Text("重复组 · \(group.files.count) 项(默认保留第一项)").font(.caption.weight(.bold)).foregroundStyle(.blue) - ForEach(Array(group.files.enumerated()), id: \.element.id) { index, file in - Toggle(isOn: Binding(get: { selectedIDs.contains(file.id) }, set: { if $0 { selectedIDs.insert(file.id) } else { selectedIDs.remove(file.id) } })) { - HStack { Image(systemName: file.icon).foregroundStyle(.blue); Text(file.name).lineLimit(1); Spacer(); Text(index == 0 ? "保留" : file.formattedSize).font(.caption).foregroundStyle(index == 0 ? .green : .secondary) } - }.disabled(index == 0) + 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): @@ -1443,7 +1654,17 @@ private struct TMDBToolPage: View { .confirmationDialog("确认执行已选择的 TMDB 整理任务?", isPresented: $showRunConfirmation) { Button("开始整理", role: .destructive) { Task { await model.runTMDBJobs(destinationID: destinationID) } } } message: { Text("只会处理你手动选择并开启“执行”的条目。媒体文件将移动到目标目录;NFO 会在移动后写入。") } - .onAppear { workflow = model.tmdbWorkflow } + .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 { @@ -1456,16 +1677,20 @@ private struct ToolSettingsPage: View { @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) } - HStack { Spacer(); Button("清空") { key = ""; proxyHost = ""; proxyPort = "" }; Button("保存配置") { model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort) }.buttonStyle(.borderedProminent).tint(.orange) } + 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 } } } -private struct ToolPageCard: View { +struct ToolPageCard: View { let icon: String let tint: Color let title: String @@ -1480,7 +1705,7 @@ private struct ToolPageCard: View { } } -private struct CreateFolderSheet: View { +struct CreateFolderSheet: View { @Binding var name: String let onCreate: (String) -> Void let onCancel: () -> Void @@ -1512,7 +1737,7 @@ private struct CreateFolderSheet: View { } } -private struct RenameSheet: View { +struct RenameSheet: View { let file: CloudFile let onRename: (String) -> Void @Environment(\.dismiss) private var dismiss @@ -1552,12 +1777,15 @@ private struct RenameSheet: View { } } -private struct WorkspaceSettings: View { +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) { @@ -1574,8 +1802,12 @@ private struct WorkspaceSettings: View { } 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) } + 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) @@ -1584,60 +1816,7 @@ private struct WorkspaceSettings: View { } } -private struct ToolsPanel: View { - @ObservedObject var model: AppModel - @Binding var isShowingSettings: Bool - @Binding var isShowingDuplicates: Bool - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { SheetHeader(icon: "wand.and.stars", title: "工作区工具", subtitle: "批处理当前目录和媒体文件"); Spacer(); Button("关闭") { dismiss() } } - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - ToolAction(title: "清理空文件夹", detail: "递归扫描并删除空目录", icon: "sparkles", tint: .orange) { Task { await model.cleanEmptyFolders(); dismiss() } } - ToolAction(title: "扫描重复文件", detail: "按 GCID 查找重复资源", icon: "square.on.square", tint: .blue) { Task { await model.scanDuplicates(); isShowingDuplicates = true; dismiss() } } - ToolAction(title: "排除小文件扫描", detail: "忽略小于 1 MB 的文件", icon: "square.on.square.fill", tint: .purple) { Task { await model.scanDuplicates(excludingSmallFiles: true); isShowingDuplicates = true; dismiss() } } - ToolAction(title: "相似文件夹", detail: "按标准化名称查找相似目录", icon: "rectangle.3.group", tint: .green) { model.findSimilarFolders(); dismiss() } - } - Divider() - Button { isShowingSettings = true; dismiss() } label: { Label("工作区设置", systemImage: "gearshape") }.buttonStyle(.bordered) - } - .padding(24) - .frame(width: 620) - .softCard(radius: 26) - } -} - -private struct ToolAction: View { - let title: String - let detail: String - let icon: String - let tint: Color - let action: () -> Void - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 12) { - Image(systemName: icon) - .font(.title3.weight(.semibold)) - .foregroundStyle(tint) - .frame(width: 38, height: 38) - .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.body.weight(.semibold)) - Text(detail).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, minHeight: 116, alignment: .leading) - .padding(14) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay { RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(.white.opacity(0.35), lineWidth: 1) } - .contentShape(Rectangle()) - }.buttonStyle(.plain) - } -} - -private struct DuplicateResults: View { +struct DuplicateResults: View { @ObservedObject var model: AppModel @Environment(\.dismiss) private var dismiss var body: some View { diff --git a/guangya_mac/FileMetadataCache.swift b/guangya_mac/FileMetadataCache.swift new file mode 100644 index 0000000..82024aa --- /dev/null +++ b/guangya_mac/FileMetadataCache.swift @@ -0,0 +1,265 @@ +import Foundation +import SQLite3 + +struct CachedFileMetadata: Sendable { + let fileID: String + let isDirectory: Bool + let gcid: String? + let size: Int64? + let modifiedEpoch: Int64? + let subDirectoryCount: Int? + let subFileCount: Int? + + var isUsable: Bool { + isDirectory ? (size != nil || subDirectoryCount != nil || subFileCount != nil) : !(gcid?.isEmpty ?? true) + } + + func applying(to file: CloudFile) -> CloudFile { + var result = file + if let gcid, !gcid.isEmpty, result.gcid?.isEmpty != false { result.gcid = gcid } + if let size, result.size == nil || (result.isDirectory && result.size == 0) { result.size = size } + if let modifiedEpoch, result.modifiedAt.isEmpty { result.modifiedAt = Self.formatDate(modifiedEpoch) } + if let subDirectoryCount, result.subDirectoryCount == nil { result.subDirectoryCount = subDirectoryCount } + if let subFileCount, result.subFileCount == nil { result.subFileCount = subFileCount } + return result + } + + var detailValue: JSONValue { + var fileInfo: [String: JSONValue] = ["fileId": .string(fileID)] + if let gcid { fileInfo["gcid"] = .string(gcid) } + if let modifiedEpoch { fileInfo["utime"] = .number(Double(modifiedEpoch)) } + var sizeInfo: [String: JSONValue] = [:] + if let size { sizeInfo["size"] = .number(Double(size)) } + if let subDirectoryCount { sizeInfo["subDirCount"] = .number(Double(subDirectoryCount)) } + if let subFileCount { sizeInfo["subFileCount"] = .number(Double(subFileCount)) } + return .object(["data": .object(["fileInfo": .object(fileInfo), "sizeInfo": .object(sizeInfo)]), "cache": .bool(true)]) + } + + private static func formatDate(_ epoch: Int64) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch))) + } +} + +actor FileMetadataCache { + static let shared = FileMetadataCache() + + private var database: OpaquePointer? + private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + private init() { + guard let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } + let directory = baseURL.appendingPathComponent("com.example.guangya-mac", isDirectory: true) + do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } + catch { return } + let url = directory.appendingPathComponent("file-metadata.sqlite3") + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, + let database else { self.database = nil; return } + Self.execute(database, "PRAGMA journal_mode=WAL;") + Self.execute(database, "PRAGMA synchronous=NORMAL;") + Self.execute(database, """ + CREATE TABLE IF NOT EXISTS file_index ( + file_id TEXT PRIMARY KEY NOT NULL, + is_directory INTEGER NOT NULL DEFAULT 0, + gcid TEXT, + modified_epoch INTEGER, + directory_size INTEGER, + sub_directory_count INTEGER, + sub_file_count INTEGER, + cached_at REAL NOT NULL + ); + """) + Self.execute(database, """ + CREATE TABLE IF NOT EXISTS gcid_details ( + gcid TEXT PRIMARY KEY NOT NULL, + size INTEGER, + cached_at REAL NOT NULL + ); + """) + Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_file_index_gcid ON file_index(gcid);") + Self.migrateLegacyTable(database) + } + + deinit { if let database { sqlite3_close(database) } } + + func metadata(for fileIDs: [String]) -> [String: CachedFileMetadata] { + guard let database, !fileIDs.isEmpty else { return [:] } + var statement: OpaquePointer? + let sql = """ + SELECT i.file_id, i.is_directory, i.gcid, + CASE WHEN i.is_directory = 1 THEN i.directory_size ELSE g.size END, + i.modified_epoch, i.sub_directory_count, i.sub_file_count + FROM file_index AS i + LEFT JOIN gcid_details AS g ON g.gcid = i.gcid + WHERE i.file_id = ?1; + """ + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [:] } + defer { sqlite3_finalize(statement) } + var result: [String: CachedFileMetadata] = [:] + for fileID in fileIDs { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + sqlite3_bind_text(statement, 1, fileID, -1, transient) + guard sqlite3_step(statement) == SQLITE_ROW else { continue } + let value = Self.read(statement) + result[value.fileID] = value + } + return result + } + + func metadata(for fileID: String) -> CachedFileMetadata? { + metadata(for: [fileID])[fileID] + } + + func save(fileID: String, isDirectory: Bool, detail: JSONValue) { + let metadata = CachedFileMetadata( + fileID: fileID, + isDirectory: isDirectory, + gcid: detail.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]), + size: detail.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]), + modifiedEpoch: detail.firstInt64Deep(["utime", "ctime"]), + subDirectoryCount: detail.firstIntDeep(["subDirCount"]), + subFileCount: detail.firstIntDeep(["subFileCount"]) + ) + save(metadata) + } + + func save(files: [CloudFile]) { + for file in files { + let hasGCID = !(file.gcid?.isEmpty ?? true) + let hasDirectoryCounts = file.subDirectoryCount != nil || file.subFileCount != nil + guard (!file.isDirectory && hasGCID) || (file.isDirectory && hasDirectoryCounts) else { continue } + save(CachedFileMetadata( + fileID: file.id, + isDirectory: file.isDirectory, + gcid: file.gcid, + size: file.size, + modifiedEpoch: nil, + subDirectoryCount: file.subDirectoryCount, + subFileCount: file.subFileCount + )) + } + } + + func save(_ metadata: CachedFileMetadata) { + guard let database else { return } + let existingGCID = metadata.isDirectory ? nil : self.metadata(for: metadata.fileID)?.gcid + let effectiveGCID = metadata.gcid?.isEmpty == false ? metadata.gcid : existingGCID + var indexStatement: OpaquePointer? + let sql = """ + INSERT INTO file_index + (file_id, is_directory, gcid, modified_epoch, directory_size, sub_directory_count, sub_file_count, cached_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(file_id) DO UPDATE SET + is_directory = excluded.is_directory, + gcid = COALESCE(excluded.gcid, file_index.gcid), + modified_epoch = COALESCE(excluded.modified_epoch, file_index.modified_epoch), + directory_size = COALESCE(excluded.directory_size, file_index.directory_size), + sub_directory_count = COALESCE(excluded.sub_directory_count, file_index.sub_directory_count), + sub_file_count = COALESCE(excluded.sub_file_count, file_index.sub_file_count), + cached_at = excluded.cached_at; + """ + guard sqlite3_prepare_v2(database, sql, -1, &indexStatement, nil) == SQLITE_OK, let indexStatement else { return } + sqlite3_bind_text(indexStatement, 1, metadata.fileID, -1, transient) + sqlite3_bind_int(indexStatement, 2, metadata.isDirectory ? 1 : 0) + Self.bind(effectiveGCID, to: indexStatement, at: 3, transient: transient) + Self.bind(metadata.modifiedEpoch, to: indexStatement, at: 4) + Self.bind(metadata.isDirectory ? metadata.size : nil, to: indexStatement, at: 5) + Self.bind(metadata.subDirectoryCount.map(Int64.init), to: indexStatement, at: 6) + Self.bind(metadata.subFileCount.map(Int64.init), to: indexStatement, at: 7) + sqlite3_bind_double(indexStatement, 8, Date().timeIntervalSince1970) + sqlite3_step(indexStatement) + sqlite3_finalize(indexStatement) + + guard !metadata.isDirectory, let effectiveGCID, !effectiveGCID.isEmpty else { return } + var detailStatement: OpaquePointer? + let detailSQL = """ + INSERT INTO gcid_details (gcid, size, cached_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(gcid) DO UPDATE SET + size = COALESCE(excluded.size, gcid_details.size), + cached_at = excluded.cached_at; + """ + guard sqlite3_prepare_v2(database, detailSQL, -1, &detailStatement, nil) == SQLITE_OK, let detailStatement else { return } + defer { sqlite3_finalize(detailStatement) } + sqlite3_bind_text(detailStatement, 1, effectiveGCID, -1, transient) + Self.bind(metadata.size, to: detailStatement, at: 2) + sqlite3_bind_double(detailStatement, 3, Date().timeIntervalSince1970) + sqlite3_step(detailStatement) + } + + private static func read(_ statement: OpaquePointer) -> CachedFileMetadata { + CachedFileMetadata( + fileID: String(cString: sqlite3_column_text(statement, 0)), + isDirectory: sqlite3_column_int(statement, 1) != 0, + gcid: text(statement, column: 2), + size: integer(statement, column: 3), + modifiedEpoch: integer(statement, column: 4), + subDirectoryCount: integer(statement, column: 5).map(Int.init), + subFileCount: integer(statement, column: 6).map(Int.init) + ) + } + + private static func text(_ statement: OpaquePointer, column: Int32) -> String? { + guard sqlite3_column_type(statement, column) != SQLITE_NULL, let value = sqlite3_column_text(statement, column) else { return nil } + return String(cString: value) + } + + private static func integer(_ statement: OpaquePointer, column: Int32) -> Int64? { + sqlite3_column_type(statement, column) == SQLITE_NULL ? nil : sqlite3_column_int64(statement, column) + } + + private static func bind(_ value: String?, to statement: OpaquePointer, at index: Int32, transient: sqlite3_destructor_type) { + if let value { sqlite3_bind_text(statement, index, value, -1, transient) } + else { sqlite3_bind_null(statement, index) } + } + + private static func bind(_ value: Int64?, to statement: OpaquePointer, at index: Int32) { + if let value { sqlite3_bind_int64(statement, index, value) } + else { sqlite3_bind_null(statement, index) } + } + + @discardableResult + private static func execute(_ database: OpaquePointer, _ sql: String) -> Bool { + sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK + } + + private static func migrateLegacyTable(_ database: OpaquePointer) { + guard tableExists(database, name: "file_metadata") else { return } + execute(database, "BEGIN IMMEDIATE TRANSACTION;") + let mapped = execute(database, """ + INSERT OR REPLACE INTO file_index + (file_id, is_directory, gcid, modified_epoch, directory_size, sub_directory_count, sub_file_count, cached_at) + SELECT file_id, is_directory, gcid, modified_epoch, + CASE WHEN is_directory = 1 THEN size ELSE NULL END, + sub_directory_count, sub_file_count, cached_at + FROM file_metadata; + """) + let detailed = execute(database, """ + INSERT OR REPLACE INTO gcid_details (gcid, size, cached_at) + SELECT gcid, size, cached_at + FROM file_metadata + WHERE is_directory = 0 AND gcid IS NOT NULL AND gcid != ''; + """) + guard mapped, detailed else { + execute(database, "ROLLBACK;") + return + } + guard execute(database, "DROP TABLE file_metadata;") else { + execute(database, "ROLLBACK;") + return + } + execute(database, "COMMIT;") + } + + private static func tableExists(_ database: OpaquePointer, name: String) -> Bool { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1;", -1, &statement, nil) == SQLITE_OK, + let statement else { return false } + defer { sqlite3_finalize(statement) } + sqlite3_bind_text(statement, 1, name, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + return sqlite3_step(statement) == SQLITE_ROW + } +} diff --git a/guangya_mac/GuangyaAPI.swift b/guangya_mac/GuangyaAPI.swift index 4a2f6f2..0d7dd22 100644 --- a/guangya_mac/GuangyaAPI.swift +++ b/guangya_mac/GuangyaAPI.swift @@ -232,7 +232,7 @@ final class GuangyaAPI: @unchecked Sendable { func fsDetail(fileID: String) async throws -> JSONValue { // Build the payload explicitly so the 19-digit resource ID can never pass // through the Any/NSNumber conversion path and lose its JSON string type. - try await apiRequest("/userres/v1/file/get_file_detail", body: .object(["fileId": .string(fileID)])) + try await apiRequest("/nd.bizuserres.s/v1/file/get_file_detail", body: .object(["fileId": .string(fileID)])) } func vodDownloadURL(fileID: String, gcid: String) async throws -> JSONValue { @@ -517,4 +517,3 @@ final class GuangyaAPI: @unchecked Sendable { private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset.. = [] + @State private var renameFile: CloudFile? + @State private var isCreatingFolder = false + @State private var isImporting = false + @State private var isShowingSettings = false + @State private var isShowingDuplicates = false + @State private var isInspectorPresented = false + @State private var selectedTool: WorkspaceTool? + @State private var folderName = "" + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var previewFile: CloudFile? + @State private var globalSearchText = "" + + private var selectedFile: CloudFile? { + selectedFileIDs.count == 1 ? model.files.first { $0.id == selectedFileIDs.first } : nil + } + + var body: some View { + ZStack { + AppBackdrop() + NavigationSplitView(columnVisibility: $columnVisibility) { + WorkspaceSidebar(model: model, selectedTool: $selectedTool, columnVisibility: $columnVisibility) + .navigationSplitViewColumnWidth(min: 210, ideal: 238, max: 280) + } detail: { + if let selectedTool { + ToolWorkspacePage(model: model, tool: selectedTool) + } else { + FilesBrowser( + model: model, + selectedFileIDs: $selectedFileIDs, + globalSearchText: $globalSearchText, + renameFile: $renameFile, + isCreatingFolder: $isCreatingFolder, + isImporting: $isImporting, + isShowingSettings: $isShowingSettings, + isShowingDuplicates: $isShowingDuplicates, + onPreview: { previewFile = $0 }, + onOpenTool: { selectedTool = $0 }, + onShowInspector: { isInspectorPresented = true } + ) + .frame(minWidth: 680, maxWidth: .infinity, maxHeight: .infinity) + .inspector(isPresented: $isInspectorPresented) { + FileInspector( + model: model, + file: selectedFile, + onPreview: { previewFile = $0 }, + onClose: { isInspectorPresented = false } + ) + .inspectorColumnWidth(min: 280, ideal: 320, max: 420) + } + } + } + .scrollContentBackground(.hidden) + .toolbar { + ToolbarItem(placement: .principal) { + AppTitleToolbar(model: model, selectedTool: selectedTool, searchText: $globalSearchText) + } + ToolbarItem(placement: .primaryAction) { + if selectedTool == nil { + Button { isInspectorPresented.toggle() } label: { + Image(systemName: "sidebar.trailing") + } + .help(isInspectorPresented ? "隐藏详情检查器" : "显示详情检查器") + .accessibilityLabel(isInspectorPresented ? "隐藏详情检查器" : "显示详情检查器") + .keyboardShortcut("i", modifiers: [.command, .option]) + .disabled(selectedFile == nil) + } + } + } + } + .frame(minWidth: 1040, minHeight: 700) + .sheet(item: $renameFile) { file in RenameSheet(file: file) { name in Task { await model.rename(file, to: name) } } } + .sheet(isPresented: $isCreatingFolder) { + CreateFolderSheet(name: $folderName) { name in + isCreatingFolder = false + folderName = "" + Task { await model.createFolder(name: name) } + } onCancel: { isCreatingFolder = false; folderName = "" } + } + .sheet(isPresented: $isShowingSettings) { WorkspaceSettings(model: model) } + .sheet(isPresented: $isShowingDuplicates) { DuplicateResults(model: model) } + .sheet(item: $previewFile) { file in FilePreviewSheet(model: model, file: file) } + .fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in + switch result { + case .success(let urls): if let url = urls.first { Task { await model.upload(url: url) } } + case .failure(let error): model.errorMessage = error.localizedDescription + } + } + .alert("提示", isPresented: Binding(get: { !model.lastActionMessage.isEmpty }, set: { if !$0 { model.lastActionMessage = "" } })) { + Button("确定", role: .cancel) { model.lastActionMessage = "" } + } message: { Text(model.lastActionMessage) } + .task { + if model.files.isEmpty && !model.isLoadingFiles { await model.refresh() } + } + .onChange(of: selectedTool) { _, tool in + if tool != nil { isInspectorPresented = false } + } + .onChange(of: model.section) { _, _ in + selectedFileIDs = [] + selectedTool = nil + isInspectorPresented = false + globalSearchText = "" + Task { await model.refresh() } + } + .onChange(of: model.files.map(\.id)) { _, ids in + selectedFileIDs.formIntersection(Set(ids)) + if selectedFileIDs.isEmpty { isInspectorPresented = false } + } + .onChange(of: model.folderPath) { _, _ in + selectedFileIDs = [] + isInspectorPresented = false + globalSearchText = "" + } + } +} + +private struct AppTitleToolbar: View { + @ObservedObject var model: AppModel + let selectedTool: WorkspaceTool? + @Binding var searchText: String + + private var supportsSearch: Bool { + selectedTool == nil && [.files, .photos, .videos, .audio, .documents].contains(model.section) + } + + var body: some View { + HStack(spacing: 12) { + Label(selectedTool?.rawValue ?? model.section.rawValue, systemImage: selectedTool?.icon ?? model.section.icon) + .font(.headline.weight(.semibold)) + if supportsSearch { + TextField("搜索当前列表", text: $searchText) + .textFieldStyle(.roundedBorder) + .frame(width: 260) + } + } + } +}