diff --git a/guangya_mac/AppModel.swift b/guangya_mac/AppModel.swift index 46a21a7..9c7683e 100644 --- a/guangya_mac/AppModel.swift +++ b/guangya_mac/AppModel.swift @@ -11,14 +11,35 @@ final class AppModel: ObservableObject { @Published var folderPath: [FolderPath] = [] @Published var isLoadingFiles = false @Published var isLoadingFolderSizes = false + @Published var detailLoadingIDs: Set = [] + @Published var actionLoadingIDs: Set = [] + @Published var currentPage = 0 + @Published var pageSize = 50 + @Published var totalPages = 1 + @Published var serverSort: FileSort = .name + @Published var serverSortDirection: SortDirection = .ascending + @Published var sizeLoadingCompleted = 0 + @Published var sizeLoadingTotal = 0 @Published var duplicateGroups: [DuplicateGroup] = [] @Published var similarFolders: [CloudFile] = [] @Published var detail: JSONValue? + @Published var detailFileID: String? @Published var lastActionMessage = "" @Published var tmdbAPIKey = UserDefaults.standard.string(forKey: "guangya.tmdbAPIKey") ?? "" @Published var tmdbProxyHost = UserDefaults.standard.string(forKey: "guangya.tmdbProxyHost") ?? "" @Published var tmdbProxyPort = UserDefaults.standard.string(forKey: "guangya.tmdbProxyPort") ?? "" + @Published var tmdbWorkflow = TMDBWorkflowConfig.load() + @Published var tmdbJobs: [TMDBJob] = [] + @Published var tmdbTarget: CloudFile? + @Published var isRunningTMDBJob = false @Published var isAnalyzing = false + @Published var activeScanRequest: ScanRequest? + @Published var scanResult: ScanResult? + @Published var liveScanItems: [ScanItem] = [] + @Published var scanPickerFolders: [CloudFile] = [] + @Published var isLoadingScanPicker = false + @Published var scanProgress = ScanProgress() + @Published var isScanning = false @Published var isBusy = false @Published var statusMessage = "" @Published var errorMessage = "" @@ -34,13 +55,22 @@ final class AppModel: ObservableObject { let api: GuangyaAPI private var qrPollingTask: Task? private var countdownTask: Task? + private var detailEnrichmentTask: Task? + private var listResponseTask: Task? + private var activeListRequestID = UUID() + private var detailEnrichmentGeneration = UUID() + private var detailCache: [String: (value: JSONValue, expiresAt: Date)] = [:] + private var detailRequestTasks: [String: Task] = [:] + private let detailRequestGate = DetailRequestGate(limit: 4) + private var isHandlingAuthorizationExpiry = false + private var scanTask: Task? init() { let defaults = UserDefaults.standard let access = defaults.string(forKey: "guangya.accessToken") ?? "" let refresh = defaults.string(forKey: "guangya.refreshToken") api = GuangyaAPI(accessToken: access, refreshToken: refresh) - if !access.isEmpty { + if !access.isEmpty || refresh != nil { isSignedIn = true Task { await loadAccount() } } @@ -95,11 +125,39 @@ final class AppModel: ObservableObject { func refresh() async { guard isSignedIn else { return } - await perform("正在刷新…") { [self] in await loadFiles() } + // Refresh is an explicit recovery action: abandon a stalled first-page + // request and start a fresh one instead of leaving the button disabled. + listResponseTask?.cancel() + detailEnrichmentTask?.cancel() + activeListRequestID = UUID() + await loadFiles(force: true) + } + + func reloadCurrentListSizes() { + schedulePageDetailEnrichment(force: true) + } + + func loadVisibleItemDetails(_ file: CloudFile) { + guard file.size == nil || file.gcid == nil || file.modifiedAt.isEmpty 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) + 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 } + if let gcid = detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]) { self.files[index].gcid = gcid } + if let epoch = detail?.firstInt64Deep(["utime", "ctime"]) { self.files[index].modifiedAt = Self.formatDate(epoch) } + if let count = detail?.firstIntDeep(["subDirCount"]) { self.files[index].subDirectoryCount = count } + if let count = detail?.firstIntDeep(["subFileCount"]) { self.files[index].subFileCount = count } + self.detailLoadingIDs.remove(file.id) + } } func openFolder(_ file: CloudFile) async { guard file.isDirectory, !isLoadingFiles else { return } + currentPage = 0 folderPath.append(FolderPath(id: file.id, name: file.name)) await loadFiles() } @@ -107,12 +165,14 @@ final class AppModel: ObservableObject { func navigateToRoot() async { guard !folderPath.isEmpty, !isLoadingFiles else { return } folderPath.removeAll() + currentPage = 0 await loadFiles() } func navigateToFolder(at index: Int) async { guard folderPath.indices.contains(index), !isLoadingFiles else { return } folderPath.removeSubrange((index + 1)..) async { + let targets = files.filter { ids.contains($0.id) } + guard !targets.isEmpty else { return } + await perform("正在删除 \(targets.count) 项…") { [self] in + actionLoadingIDs.formUnion(ids) + defer { actionLoadingIDs.subtract(ids) } + // One request preserves the server's batch semantics and avoids partial UI updates. + _ = try await api.fsDelete(fileIDs: targets.map(\.id)) + files.removeAll { ids.contains($0.id) } + duplicateGroups = duplicateGroups.compactMap { group in + let kept = group.files.filter { !ids.contains($0.id) } + return kept.count > 1 ? DuplicateGroup(id: group.id, files: kept) : nil + } + similarFolders.removeAll { ids.contains($0.id) } + } } + func delete(_ file: CloudFile) async { + actionLoadingIDs.insert(file.id) + defer { actionLoadingIDs.remove(file.id) } + await perform("正在删除…") { [self] in + _ = try await api.fsDelete(fileIDs: [file.id]) + // The list, its enriched size data, and folder counts are already local. + // Apply the successful deletion in place instead of reloading every row. + files.removeAll { $0.id == file.id } + duplicateGroups = duplicateGroups.compactMap { group in + let kept = group.files.filter { $0.id != file.id } + return kept.count > 1 ? DuplicateGroup(id: group.id, files: kept) : nil + } + similarFolders.removeAll { $0.id == file.id } + } + } + + func goToPage(_ page: Int) async { + let target = max(0, min(page, totalPages - 1)) + guard target != currentPage, !isLoadingFiles else { return } + currentPage = target + await loadFiles() + } + + func setPageSize(_ size: Int) async { + guard size != pageSize else { return } + pageSize = size + currentPage = 0 + await loadFiles() + } + + func setServerSort(_ sort: FileSort) async { + if serverSort == sort { serverSortDirection = serverSortDirection == .ascending ? .descending : .ascending } + else { serverSort = sort; serverSortDirection = .ascending } + currentPage = 0 + await loadFiles() + } + + func setServerSort(_ sort: FileSort, direction: SortDirection) async { + guard serverSort != sort || serverSortDirection != direction else { return } + serverSort = sort + serverSortDirection = direction + currentPage = 0 + await loadFiles() + } + + var scanScopeLocationName: String { + if section == .files { return folderPath.isEmpty ? "云盘根目录" : folderPath.map(\.name).joined(separator: " / ") } + return section.rawValue + } + + var currentScanRootID: String? { section == .files ? folderPath.last?.id : nil } + func upload(url: URL) async { await perform("正在上传 \(url.lastPathComponent)…") { [self] in _ = try await api.fileUpload(url: url, parentID: folderPath.last?.id); await loadFiles() } } func open(_ file: CloudFile) async { - if file.isDirectory { await openFolder(file) } else { await download(file) } + if file.isDirectory { await openFolder(file) } else { await preview(file) } + } + + func remoteURL(for file: CloudFile) async throws -> URL { + let detail = try await api.fsDetail(fileID: file.id) + + // Images expose their signed preview directly in picInfo.previewUrl. + if file.fileType == 1, + let value = detail.firstStringDeep(["previewUrl", "previewURL", "originalUrl", "originalURL", "thumbnail"]), + let url = URL(string: value) { return url } + + // The web player does not use the generic download endpoint for videos. + // It selects a videoResource GCID then asks get_vod_download_url for the + // signed media address, which AVPlayer can consume directly. + if file.fileType == 2, + let resources = detail.firstArrayDeep(["videoResource"]), + let defaultResource = resources.first(where: { $0["info"]?["defaultResolution"]?.boolValue == true }) ?? resources.first, + let gcid = defaultResource.firstStringDeep(["gcid"]), + let result = try? await api.vodDownloadURL(fileID: file.id, gcid: gcid), + let value = result.firstStringDeep(["signedURL", "signedUrl", "url", "downloadUrl", "download_url", "dlink"]), + let url = URL(string: value) { return url } + + // Documents and unsupported media fall back to their signed direct link. + let result = try await api.downloadURL(fileID: file.id) + guard let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]), + let url = URL(string: value) else { throw GuangyaAPIError.missingField("可预览地址或直链") } + return url + } + + func preview(_ file: CloudFile) async { + await perform("正在准备预览…") { [self] in + NSWorkspace.shared.open(try await remoteURL(for: file)) + } } func download(_ file: CloudFile) async { await perform("正在准备下载…") { [self] in - let result = try await api.downloadURL(fileID: file.id) - guard let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]) else { throw GuangyaAPIError.missingField("下载地址") } - guard let url = URL(string: value) else { throw GuangyaAPIError.invalidResponse } + NSWorkspace.shared.open(try await remoteURL(for: file)) + } + } + + func playWithExternalPlayer(_ file: CloudFile) async { + await perform("正在交给外部播放器…") { [self] in + let url = try await remoteURL(for: file) + let candidates = ["IINA", "VLC"] + for name in candidates { + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: name == "IINA" ? "com.colliderli.iina" : "org.videolan.vlc") { + try await NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: .init()) + lastActionMessage = "已使用 \(name) 打开“\(file.name)”" + return + } + } NSWorkspace.shared.open(url) + lastActionMessage = "未发现 IINA 或 VLC,已交给系统默认播放器" } } @@ -166,23 +336,21 @@ final class AppModel: ObservableObject { } func showDetails(_ file: CloudFile) async { - isBusy = true - statusMessage = "正在读取详情…" + // Inspector loading is independent from global isBusy; bind the answer to + // its resource ID so a slower previous selection can never overwrite it. + detailFileID = file.id + detail = nil + detailLoadingIDs.insert(file.id) + defer { detailLoadingIDs.remove(file.id) } do { - detail = try await api.fsDetail(fileID: file.id) - } catch { - var fallback: [String: JSONValue] = [ - "fileId": .string(file.id), - "fileName": .string(file.name), - "dirType": .number(file.isDirectory ? 1 : 0), - "resType": .number(Double(file.fileType)) - ] - if let size = file.size { fallback["size"] = .number(Double(size)) } - if let gcid = file.gcid { fallback["gcid"] = .string(gcid) } - detail = .object(["fileInfo": .object(fallback), "message": .string("详情请求失败:\(error.localizedDescription),已显示当前列表详情")]) + let value = try await cachedDetail(for: file.id) + 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)" } - isBusy = false - statusMessage = "" } func share(_ file: CloudFile) async { @@ -199,21 +367,177 @@ final class AppModel: ObservableObject { } func cleanEmptyFolders() async { - await perform("正在扫描空文件夹…") { [self] in - var emptyIDs: [String] = [] - @MainActor func scan(parentID: String?) async throws { - let items = extractFiles(from: try await api.fsFiles(parentID: parentID)) - for item in items where item.isDirectory { - let children = extractFiles(from: try await api.fsFiles(parentID: item.id)) - if children.isEmpty { emptyIDs.append(item.id) } - else { try await scan(parentID: item.id) } + // Kept for legacy menu callers: this is now scan-only and never deletes. + startScan(ScanRequest(kind: .emptyFolders, scope: .wholeDrive, rootID: nil, rootName: "云盘根目录", excludeFilesSmallerThan: nil)) + } + + func startScan(_ request: ScanRequest) { + guard !isScanning else { return } + scanTask?.cancel() + activeScanRequest = request + scanResult = nil + liveScanItems = [] + scanProgress = ScanProgress(phase: "正在准备扫描…") + isScanning = true + 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 } + } + } + + func cancelScan() { scanTask?.cancel() } + + func loadScanPickerFolders(parentID: String? = nil) async { + guard !isLoadingScanPicker else { return } + isLoadingScanPicker = true + defer { isLoadingScanPicker = false } + do { scanPickerFolders = try await allFiles(parentID: parentID, pageSize: 5_000).filter(\.isDirectory) } + catch { errorMessage = error.localizedDescription } + } + + func deleteScannedEmptyFolders(_ ids: Set) async { + guard let result = scanResult, result.request.kind == .emptyFolders, !ids.isEmpty else { return } + await perform("正在复核并删除空文件夹…") { [self] in + var deleted = 0; var skipped = 0 + for folder in result.emptyFolders.reversed() where ids.contains(folder.id) { + 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 + } + scanResult = nil + files.removeAll { ids.contains($0.id) } + lastActionMessage = skipped == 0 ? "已删除 \(deleted) 个空文件夹" : "已删除 \(deleted) 个,跳过 \(skipped) 个已变更文件夹" + } + } + + func deleteScannedFiles(_ ids: Set) async { + guard let result = scanResult, result.request.kind != .emptyFolders, !ids.isEmpty else { return } + await perform("正在删除已选文件…") { [self] in + var deleted = 0 + for id in ids { + actionLoadingIDs.insert(id) + defer { actionLoadingIDs.remove(id) } + _ = try await api.fsDelete(fileIDs: [id]) + deleted += 1 + } + files.removeAll { ids.contains($0.id) } + switch result.request.kind { + case .duplicates: + let groups = result.items.compactMap { item -> DuplicateGroup? in + guard case .duplicate(let group) = item else { return nil } + let kept = group.files.filter { !ids.contains($0.id) } + return kept.count > 1 ? DuplicateGroup(id: group.id, files: kept) : nil + } + scanResult = ScanResult(request: result.request, items: groups.map(ScanItem.duplicate), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders) + case .similarFolders: + let groups = result.items.compactMap { item -> SimilarFolderGroup? in + guard case .similar(let group) = item else { return nil } + let kept = group.folders.filter { !ids.contains($0.id) } + return kept.count > 1 ? SimilarFolderGroup(id: group.id, folders: kept) : nil + } + scanResult = ScanResult(request: result.request, items: groups.map(ScanItem.similar), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders) + case .emptyFolders: break + } + lastActionMessage = "已删除 \(deleted) 项" + } + } + + 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) + switch request.kind { + case .emptyFolders: + let candidates = snapshot.folders.filter { $0.isEmptyAfterCandidates } + let finalItems = candidates.map { ScanItem.emptyFolder($0.file) } + liveScanItems = finalItems + return ScanResult(request: request, items: finalItems, foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0) + case .duplicates: + var candidates = snapshot.allFiles.filter { !$0.isDirectory } + 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 + var copy = file + if copy.gcid == nil, let detail = try? await api.fsDetail(fileID: file.id) { copy.gcid = detail.firstStringDeep(["gcid", "gcId", "hash"]) } + return copy + } + } + var all: [CloudFile] = []; for await file in group { all.append(file) }; return all + } + 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)) } + 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: + let groups = Dictionary(grouping: snapshot.allFiles.filter(\.isDirectory), by: { normalizedFolderName($0.name) }).compactMap { key, folders -> SimilarFolderGroup? in folders.count > 1 ? SimilarFolderGroup(id: key, folders: folders) : nil } + return ScanResult(request: request, items: groups.map(ScanItem.similar), foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0) + } + } + + private struct ScanTree { let allFiles: [CloudFile]; let folders: [(file: CloudFile, isEmptyAfterCandidates: Bool)] } + private func scanTree(parentID: String?, recursive: Bool) async throws -> ScanTree { + try Task.checkCancellation() + let children = try await allFiles(parentID: parentID) + // 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 }) { + let item = ScanItem.emptyFolder(parent) + if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) } + } + scanProgress.filesVisited += children.filter { !$0.isDirectory }.count + scanProgress.foldersVisited += children.filter(\.isDirectory).count + 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) + all += subtree.allFiles + candidates += subtree.folders + let hasNonEmptyChild = subtree.folders.contains { $0.file.id == folder.id && !$0.isEmptyAfterCandidates } + let hasDirectFiles = subtree.allFiles.contains { !$0.isDirectory && $0.id != folder.id } + let isEmptyCandidate = !hasNonEmptyChild && !hasDirectFiles + candidates.append((folder, isEmptyCandidate)) + if isEmptyCandidate { + let item = ScanItem.emptyFolder(folder) + if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) } + } + } else { + candidates.append((folder, true)) + if children.isEmpty { + let item = ScanItem.emptyFolder(folder) + if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) } } } - try await scan(parentID: nil) - for id in emptyIDs.reversed() { _ = try await api.fsDelete(fileIDs: [id]) } - lastActionMessage = emptyIDs.isEmpty ? "没有发现空文件夹" : "已清理 \(emptyIDs.count) 个空文件夹" - await loadFiles() } + return ScanTree(allFiles: all, folders: candidates) + } + + private func allFiles(parentID: String?, pageSize: Int = 5_000) async throws -> [CloudFile] { + // Scans intentionally use a large page. It avoids 25–100 round trips for + // ordinary media folders while still paging safely when a folder is huge. + let requestedSize = max(1, min(pageSize, 10_000)) + var page = 0; var collected: [CloudFile] = [] + while true { + try Task.checkCancellation() + scanProgress.phase = "正在读取目录内容…" + let response = try await api.fsFiles(parentID: parentID, page: page, pageSize: requestedSize) + let items = extractFiles(from: response) + collected += items + let total = response.firstIntDeep(["total", "totalCount", "count"]) + if items.count < requestedSize || (total != nil && collected.count >= total!) { break } + page += 1 + // Let SwiftUI process pending input/drawing between very large pages. + await Task.yield() + } + return Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first }) } func scanDuplicates(excludingSmallFiles: Bool = false) async { @@ -267,6 +591,199 @@ final class AppModel: ObservableObject { lastActionMessage = tmdbAPIKey.isEmpty ? "TMDB 配置已清除" : "TMDB 配置已保存" } + func saveTMDBWorkflow(_ workflow: TMDBWorkflowConfig) { + tmdbWorkflow = workflow + workflow.save() + lastActionMessage = "TMDB 工作流配置已保存" + } + + func prepareTMDBJobs(sourceID: String?, recursive: Bool, minimumSizeMB: Int, extensionsText: String) async { + guard !tmdbAPIKey.isEmpty else { errorMessage = "请先配置 TMDB API Key"; return } + isRunningTMDBJob = true + 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 minimum = Int64(max(0, minimumSizeMB)) * 1024 * 1024 + let candidates = snapshot.allFiles.filter { file in + guard !file.isDirectory, (file.size ?? 0) >= minimum else { return false } + let ext = (file.name as NSString).pathExtension.lowercased() + return allowed.isEmpty || allowed.contains(ext) + } + let subtitles = snapshot.allFiles.filter { !$0.isDirectory && ["srt", "ass", "ssa", "sub", "vtt", "sup"].contains(($0.name as NSString).pathExtension.lowercased()) } + tmdbJobs = candidates.map { file in + let base = (file.name as NSString).deletingPathExtension + let related = subtitles.filter { subtitle in + let subtitleBase = (subtitle.name as NSString).deletingPathExtension + return normalizedMediaStem(subtitleBase) == normalizedMediaStem(base) + } + return TMDBJob(id: file.id, file: file, parsed: parseMediaName(file.name), relatedSubtitles: related, match: nil, state: .pending, note: "等待识别") + } + lastActionMessage = "已建立 \(tmdbJobs.count) 个 TMDB 任务,请先预览匹配结果" + } catch { errorMessage = error.localizedDescription } + } + + func matchTMDBJobs() async { + guard !tmdbAPIKey.isEmpty, !tmdbJobs.isEmpty else { return } + isRunningTMDBJob = true + defer { isRunningTMDBJob = false } + for index in tmdbJobs.indices { + do { + let kind: TMDBMediaKind = tmdbWorkflow.mediaKind == .automatic ? (tmdbJobs[index].parsed.isEpisode ? .tv : .automatic) : tmdbWorkflow.mediaKind + let candidates = try await tmdbCandidates(query: tmdbJobs[index].parsed.title, mediaKind: kind, year: tmdbJobs[index].parsed.year) + tmdbJobs[index].candidates = candidates + tmdbJobs[index].selectedCandidateID = nil + tmdbJobs[index].match = nil + tmdbJobs[index].state = candidates.isEmpty ? .failed : .pending + tmdbJobs[index].note = candidates.isEmpty ? "没有可用候选" : "请选择正确的 TMDB 条目" + } catch { tmdbJobs[index].state = .failed; tmdbJobs[index].note = error.localizedDescription } + } + lastActionMessage = "匹配完成:\(tmdbJobs.filter { $0.state == .matched }.count) 成功,\(tmdbJobs.filter { $0.state == .failed }.count) 失败" + } + + func selectTMDBCandidate(jobID: String, candidateID: Int?) { + guard let index = tmdbJobs.firstIndex(where: { $0.id == jobID }) else { return } + tmdbJobs[index].selectedCandidateID = candidateID + tmdbJobs[index].match = tmdbJobs[index].selectedCandidate?.match + tmdbJobs[index].state = candidateID == nil ? .skipped : .matched + tmdbJobs[index].note = candidateID == nil ? "已跳过" : "已选择,等待确认" + } + + func toggleTMDBApproval(jobID: String) { + guard let index = tmdbJobs.firstIndex(where: { $0.id == jobID }), tmdbJobs[index].match != nil else { return } + tmdbJobs[index].isApproved.toggle() + tmdbJobs[index].note = tmdbJobs[index].isApproved ? "已确认执行" : "等待确认" + } + + func runTMDBJobs(destinationID: String?) async { + guard tmdbWorkflow.operation == .organize else { lastActionMessage = "当前为预览模式,不会修改云盘"; return } + guard tmdbJobs.contains(where: { $0.isApproved && $0.match != nil }) else { lastActionMessage = "请先手动选择并确认至少一个匹配项"; return } + isRunningTMDBJob = true + defer { isRunningTMDBJob = false } + 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. + let targetName: String + if tmdbJobs[index].parsed.isEpisode { + let season = String(format: "S%02d", tmdbJobs[index].parsed.season ?? 0) + targetName = safeCloudName("\(baseFolder) \(season) [tmdb-\(match.id)]") + } else { + targetName = safeCloudName("\(baseFolder) [tmdb-\(match.id)]") + } + let created = try await api.fsCreateDir(name: targetName, parentID: destinationID) + guard let folderID = created.firstStringDeep(["fileId", "id", "resId"]) else { throw GuangyaAPIError.missingField("目标文件夹 ID") } + // Metadata first: if it cannot be written, leave the source media untouched. + let details = try await api.tmdbDetails(id: match.id, mediaKind: match.mediaType == "tv" ? .tv : .movie, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) + if tmdbWorkflow.createNFO { try await uploadTMDBNFO(match, job: tmdbJobs[index], details: details, parentID: folderID) } + try await uploadTMDBArtwork(details: details, job: tmdbJobs[index], parentID: folderID) + let ext = (tmdbJobs[index].file.name as NSString).pathExtension + let mediaName: String + if tmdbJobs[index].parsed.isEpisode { + mediaName = safeCloudName("\(match.title) - \(String(format: "S%02dE%02d", tmdbJobs[index].parsed.season ?? 0, tmdbJobs[index].parsed.episode ?? 0))\(ext.isEmpty ? "" : ".\(ext)")") + } else { + let stem = safeCloudName(renderTMDBTemplate(tmdbWorkflow.fileTemplate, match: match, fallback: tmdbJobs[index].file.name)) + mediaName = ext.isEmpty || stem.hasSuffix(".\(ext)") ? stem : "\(stem).\(ext)" + } + if mediaName != tmdbJobs[index].file.name { _ = try await api.fsRename(fileID: tmdbJobs[index].file.id, newName: mediaName) } + _ = try await api.fsMove(fileIDs: [tmdbJobs[index].file.id] + tmdbJobs[index].relatedSubtitles.map(\.id), parentID: folderID) + tmdbJobs[index].state = .completed; tmdbJobs[index].note = "已整理至 \(targetName),含 \(tmdbJobs[index].relatedSubtitles.count) 个字幕" + } catch { tmdbJobs[index].state = .failed; tmdbJobs[index].note = error.localizedDescription } + } + lastActionMessage = "任务执行完成:\(tmdbJobs.filter { $0.state == .completed }.count) 项已整理" + await loadFiles() + } + + private func uploadTMDBNFO(_ match: TMDBMatch, job: TMDBJob, details: JSONValue, parentID: String) async throws { + let root = job.parsed.isEpisode ? "episodedetails" : (match.mediaType == "tv" ? "tvshow" : "movie") + let genres = (details["genres"]?.arrayValue ?? []).compactMap { $0.firstString(["name"]) }.map { "\($0.xmlEscaped)" }.joined() + let cast = (details["credits"]?["cast"]?.arrayValue ?? []).prefix(20).map { person in "\((person.firstString(["name"]) ?? "").xmlEscaped)\((person.firstString(["character"]) ?? "").xmlEscaped)\(person.firstString(["profile_path"]) ?? "")" }.joined() + let crew = details["credits"]?["crew"]?.arrayValue ?? [] + let directors = crew.filter { $0.firstString(["job"]) == "Director" }.compactMap { $0.firstString(["name"]) }.map { "\($0.xmlEscaped)" }.joined() + let rating = details["vote_average"]?.doubleValue.map { "\($0)\(details["vote_count"]?.intValue ?? 0)" } ?? "" + let episodeFields = job.parsed.isEpisode ? "\(job.parsed.season ?? 0)\(job.parsed.episode ?? 0)" : "" + let title = details.firstString(["title", "name"]) ?? match.title + let original = details.firstString(["original_title", "original_name"]) ?? "" + let plot = details.firstString(["overview"]) ?? match.overview + let premiered = details.firstString(["release_date", "first_air_date"]) ?? match.releaseDate + let xml = "<\(root)>\(match.id)\(title.xmlEscaped)\(original.xmlEscaped)\(plot.xmlEscaped)\(premiered)\(rating)\(genres)\(directors)\(cast)\(episodeFields)" + let fileName: String + if job.parsed.isEpisode { + fileName = "\(match.title) - \(String(format: "S%02dE%02d", job.parsed.season ?? 0, job.parsed.episode ?? 0)).nfo" + } else { fileName = match.mediaType == "tv" ? "tvshow.nfo" : "movie.nfo" } + let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + try xml.data(using: .utf8)?.write(to: url, options: .atomic) + defer { try? FileManager.default.removeItem(at: url) } + _ = try await api.fileUpload(url: url, parentID: parentID, contentType: "application/xml") + } + + private func uploadTMDBArtwork(details: JSONValue, job: TMDBJob, parentID: String) async throws { + func upload(_ path: String?, as name: String) async throws { + guard let path, !path.isEmpty else { return } + let data = try await api.tmdbImage(path: path, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(name) + try data.write(to: url, options: .atomic); defer { try? FileManager.default.removeItem(at: url) } + _ = try await api.fileUpload(url: url, parentID: parentID, contentType: "image/jpeg") + } + if tmdbWorkflow.downloadPoster { try await upload(details.firstString(["poster_path"]), as: "poster.jpg") } + if tmdbWorkflow.downloadBackdrop { try await upload(details.firstString(["backdrop_path"]), as: "fanart.jpg") } + if job.parsed.isEpisode, let match = job.match, let season = job.parsed.season, let episode = job.parsed.episode { + let episodeDetails = try await api.tmdbEpisodeDetails(seriesID: match.id, season: season, episode: episode, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) + try await upload(episodeDetails.firstString(["still_path"]), as: "\(job.match?.title ?? "Episode") - \(String(format: "S%02dE%02d", season, episode))-thumb.jpg") + } + if tmdbWorkflow.downloadActorImages { + for (index, person) in (details["credits"]?["cast"]?.arrayValue ?? []).prefix(12).enumerated() { + guard let path = person.firstString(["profile_path"]), let data = try? await api.tmdbImage(path: path, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) else { continue } + let actorID = person.firstInt(["id"]) ?? index + let name = safeCloudName(person.firstString(["name"]) ?? "actor") + let url = FileManager.default.temporaryDirectory.appendingPathComponent("actor-\(actorID)-\(name).jpg") + try data.write(to: url, options: .atomic); defer { try? FileManager.default.removeItem(at: url) } + _ = try await api.fileUpload(url: url, parentID: parentID, contentType: "image/jpeg") + } + } + } + + private func renderTMDBTemplate(_ template: String, match: TMDBMatch, fallback: String) -> String { + template.replacingOccurrences(of: "{title}", with: match.title.isEmpty ? fallback : match.title) + .replacingOccurrences(of: "{year}", with: String(match.releaseDate.prefix(4))) + .replacingOccurrences(of: "{tmdbId}", with: String(match.id)) + } + + func parsedTMDBInfo(for file: CloudFile) -> ParsedMediaName { parseMediaName(file.name) } + + func queryTMDBManually(for file: CloudFile, title: String, year: Int?, mediaKind: TMDBMediaKind) async { + guard !tmdbAPIKey.isEmpty else { errorMessage = "请先配置 TMDB API Key"; return } + let query = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { errorMessage = "请输入要查询的中文或英文标题"; return } + isRunningTMDBJob = true + defer { isRunningTMDBJob = false } + do { + // “自动” multi-search may contain people/collections; candidates filter + // them. If no compatible result remains, retry movie then TV rather + // than surfacing the old misleading missing-field error. + var candidates = try await tmdbCandidates(query: query, mediaKind: mediaKind, year: year) + if candidates.isEmpty, mediaKind == .automatic { + candidates = try await tmdbCandidates(query: query, mediaKind: .movie, year: year) + if candidates.isEmpty { candidates = try await tmdbCandidates(query: query, mediaKind: .tv, year: year) } + } + let parsed = parseMediaName(file.name) + tmdbJobs = [TMDBJob(id: file.id, file: file, parsed: parsed, candidates: candidates, selectedCandidateID: nil, state: candidates.isEmpty ? .failed : .pending, note: candidates.isEmpty ? "未找到匹配项,请修改标题、年份或手动填写 TMDB ID" : "请选择正确的 TMDB 条目")] + if candidates.isEmpty { lastActionMessage = "TMDB 没有找到“\(query)”的可用电影或剧集结果" } + } catch { errorMessage = "TMDB 查询失败:\(error.localizedDescription)" } + } + + func recognizeTMDBByID(_ file: CloudFile, tmdbID: Int, mediaKind: TMDBMediaKind) async { + guard tmdbID > 0 else { errorMessage = "请输入有效的 TMDB ID"; return } + // TMDB search API can accept numeric IDs only through the detail endpoint; + // preserve the explicit ID as an auditable manual choice for the job. + let match = TMDBMatch(id: tmdbID, title: parseMediaName(file.name).title, mediaType: mediaKind == .tv ? "tv" : "movie", releaseDate: "", overview: "手动指定 TMDB ID") + tmdbJobs = [TMDBJob(id: file.id, file: file, parsed: parseMediaName(file.name), match: match, candidates: [], selectedCandidateID: tmdbID, state: .matched, note: "手动指定 TMDB #\(tmdbID)", isApproved: true)] + lastActionMessage = "已建立手动 TMDB #\(tmdbID) 刮削任务,请在工具箱确认执行" + } + func recognizeTMDB(_ file: CloudFile, organize: Bool = false) async { guard !tmdbAPIKey.isEmpty else { lastActionMessage = "请先在工作区设置中保存 TMDB API Key"; return } await perform(organize ? "正在识别并整理…" : "正在识别…") { [self] in @@ -317,24 +834,48 @@ final class AppModel: ObservableObject { func logout() { stopQRLogin() + countdownTask?.cancel() + listResponseTask?.cancel() + detailEnrichmentTask?.cancel() + scanTask?.cancel() + detailRequestTasks.values.forEach { $0.cancel() } + activeListRequestID = UUID() + detailEnrichmentGeneration = UUID() + countdownTask = nil + listResponseTask = nil + scanTask = nil + detailRequestTasks = [:] + detailCache = [:] + detailEnrichmentTask = nil + detailLoadingIDs = [] + sizeLoadingCompleted = 0 + sizeLoadingTotal = 0 UserDefaults.standard.removeObject(forKey: "guangya.accessToken") UserDefaults.standard.removeObject(forKey: "guangya.refreshToken") isSignedIn = false files = [] folderPath = [] + detail = nil + detailFileID = nil + scanResult = nil + liveScanItems = [] statusMessage = "" } private func completeLogin() { - let defaults = UserDefaults.standard - defaults.set(api.accessToken, forKey: "guangya.accessToken") - if let refresh = api.refreshTokenValue { defaults.set(refresh, forKey: "guangya.refreshToken") } + persistTokens() isSignedIn = true Task { await loadAccount() } } private func loadAccount() async { - await api.prepareSession() + do { + try await api.prepareSession() + } catch { + handleAuthorizationExpiry() + return + } + persistTokens() isLoadingFiles = true defer { isLoadingFiles = false } async let userResult: JSONValue? = try? await api.userInfo() @@ -346,72 +887,219 @@ final class AppModel: ObservableObject { } if let result = await fileResult { files = extractFiles(from: result) - await enrichPageDetails() + updatePaging(from: result) + // Render the directory response immediately. Per-file detail calls are + // expensive and must never block or saturate the homepage on launch. } else { errorMessage = "文件列表加载失败,请点击刷新重试" } } - private func loadFiles() async { - guard !isLoadingFiles else { return } + private func loadFiles(force: Bool = false) async { + guard force || !isLoadingFiles else { return } + if force { listResponseTask?.cancel() } + let requestID = UUID() + activeListRequestID = requestID isLoadingFiles = true - defer { isLoadingFiles = false } + defer { if activeListRequestID == requestID { isLoadingFiles = false; listResponseTask = nil } } + let task = Task { [weak self] in + guard let self else { throw CancellationError() } + return try await self.filesResponse() + } + listResponseTask = task do { - let result = try await filesResponse() + let result = try await task.value + guard !Task.isCancelled, activeListRequestID == requestID else { return } files = extractFiles(from: result) - await enrichPageDetails() + updatePaging(from: result) + // Render the directory response immediately. Per-file detail calls are + // expensive and must never block or saturate the homepage on launch. if files.isEmpty { statusMessage = "当前文件夹暂无文件" } else if statusMessage == "当前文件夹暂无文件" { statusMessage = "" } - } catch { errorMessage = error.localizedDescription } + } catch is CancellationError { } + catch { + guard activeListRequestID == requestID else { return } + if isAuthorizationExpiry(error) { handleAuthorizationExpiry() } + else { errorMessage = error.localizedDescription } + } + } + + private func isAuthorizationExpiry(_ error: Error) -> Bool { + if case GuangyaAPIError.authorizationExpired = error { return true } + return false + } + + private func persistTokens() { + let defaults = UserDefaults.standard + defaults.set(api.accessToken, forKey: "guangya.accessToken") + if let refresh = api.refreshTokenValue { defaults.set(refresh, forKey: "guangya.refreshToken") } + } + + private func handleAuthorizationExpiry() { + guard !isHandlingAuthorizationExpiry else { return } + isHandlingAuthorizationExpiry = true + api.clearTokens() + logout() + isHandlingAuthorizationExpiry = false + } + + private func updatePaging(from response: JSONValue) { + let total = response.firstIntDeep(["total", "totalCount", "count"]) ?? files.count + let pages = max(1, Int(ceil(Double(total) / Double(max(pageSize, 1))))) + totalPages = pages + if currentPage >= pages { currentPage = pages - 1 } } private func filesResponse() async throws -> JSONValue { switch section { - case .files: return try await api.fsFiles(parentID: folderPath.last?.id) - case .photos: return try await api.fsImageList() - case .videos: return try await api.fsVideoList() - case .documents: return try await api.fsDocumentList() - case .recycle: return try await api.fsRecycleFiles() + case .files: return try await api.fsFiles(parentID: folderPath.last?.id, page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue) + case .recentViewed: return try await api.recentViewed(pageSize: pageSize) + case .recentRestored: return try await api.recentRestored(pageSize: pageSize) + case .photos: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [1], resType: 1) + case .videos: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [2], resType: 1) + case .audio: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [3], resType: 1, needPlayRecord: true) + case .documents: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [4], resType: 1) + case .recycle: return try await api.fsFiles(page: currentPage, pageSize: pageSize, orderBy: 10, dirType: 4) case .cloud, .shares: return .object([:]) } } - private func tmdbMatch(for name: String) async throws -> TMDBMatch { - let query = name.replacingOccurrences(of: "\\.[^.]+$", with: "", options: .regularExpression).replacingOccurrences(of: "[._()\\[\\]{}-]+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) - let response = try await api.tmdbSearch(query: query, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) - guard let matches = response["results"]?.arrayValue?.compactMap(TMDBMatch.init), let match = matches.first else { throw GuangyaAPIError.missingField("TMDB 识别结果") } + private func tmdbCandidates(for name: String, mediaKind: TMDBMediaKind = .automatic) async throws -> [TMDBCandidate] { + try await tmdbCandidates(query: cleanedTMDBQuery(name), mediaKind: mediaKind, year: nil) + } + + private func tmdbCandidates(query: String, mediaKind: TMDBMediaKind, year: Int?) async throws -> [TMDBCandidate] { + let response = try await api.tmdbSearch(query: query, apiKey: tmdbAPIKey, mediaKind: mediaKind, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) + return (response["results"]?.arrayValue ?? []).compactMap { TMDBCandidate(json: $0, forcedKind: mediaKind == .automatic ? nil : mediaKind) } + } + + private func tmdbMatch(for name: String, mediaKind: TMDBMediaKind = .automatic) async throws -> TMDBMatch { + guard let match = try await tmdbCandidates(for: name, mediaKind: mediaKind).first?.match else { throw GuangyaAPIError.missingField("TMDB 识别结果") } return match } + private func parseMediaName(_ name: String) -> ParsedMediaName { + let stem = (name as NSString).deletingPathExtension + let normalized = stem.replacingOccurrences(of: "[._]+", with: " ", options: .regularExpression) + let episodePatterns = ["(?i)\\bS(\\d{1,2})[ ._-]*E(\\d{1,3})\\b", "(?i)\\b(\\d{1,2})x(\\d{1,3})\\b", "(?i)第\\s*(\\d{1,2})\\s*季\\s*第?\\s*(\\d{1,3})\\s*[集话]", "(?i)\\b(\\d{1,2})[ ._-](\\d{1,3})\\b"] + var season: Int?; var episode: Int? + for pattern in episodePatterns { + guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)), match.numberOfRanges >= 3 else { continue } + season = Int((normalized as NSString).substring(with: match.range(at: 1))); episode = Int((normalized as NSString).substring(with: match.range(at: 2))); break + } + let yearRegex = try? NSRegularExpression(pattern: "\\b(19\\d{2}|20\\d{2})\\b") + let year = yearRegex?.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)).flatMap { Int((normalized as NSString).substring(with: $0.range(at: 1))) } + var title = normalized + title = title.replacingOccurrences(of: "(?i)\\bS\\d{1,2}[ ._-]*E\\d{1,3}\\b|\\b\\d{1,2}x\\d{1,3}\\b|第\\s*\\d{1,2}\\s*季\\s*第?\\s*\\d{1,3}\\s*[集话]|\\b\\d{1,2}[ ._-]\\d{1,3}\\b", with: " ", options: .regularExpression) + title = title.replacingOccurrences(of: "(?i)\\b(19\\d{2}|20\\d{2}|2160p|1080p|720p|480p|web[- ]?dl|webrip|bluray|brrip|remux|x26[45]|h\\.?26[45]|hevc|av1|aac|dts|atmos|hdr10?\\+?|dv|proper|repack|extended|complete|season|中字|简体|繁体|chs|cht|eng)\\b", with: " ", options: .regularExpression) + title = title.replacingOccurrences(of: "[\\[\\](){}._-]+", with: " ", options: .regularExpression).replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) + func first(_ pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]), let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)) else { return nil } + return (normalized as NSString).substring(with: match.range) + } + let resolution = first("\\b(2160p|1080p|720p|480p|4k)\\b") + let source = first("\\b(WEB[- ]?DL|WEBRip|BluRay|BDRip|REMUX|HDTV|DVD|UHD)\\b") + let videoCodec = first("\\b(x26[45]|h\\.?26[45]|HEVC|AV1|VC-1)\\b") + let audio = first("\\b(Atmos|TrueHD|DTS(?:-HD)?|DDP?(?: ?[0-9.]+)?|AAC|FLAC)\\b") + let isDiscStructure = normalized.range(of: "(?i)\\b(BDMV|CERTIFICATE|VIDEO_TS|\\.iso)\\b", options: .regularExpression) != nil + return ParsedMediaName(title: title.isEmpty ? stem : title, year: year, season: season, episode: episode, isEpisode: season != nil && episode != nil, resolution: resolution, source: source, videoCodec: videoCodec, audio: audio, isDiscStructure: isDiscStructure) + } + + private func normalizedMediaStem(_ name: String) -> String { parseMediaName(name).title.lowercased().replacingOccurrences(of: "\\s+", with: "", options: .regularExpression) } + + private func safeCloudName(_ name: String) -> String { + let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|\\0") + let cleaned = name.components(separatedBy: invalid).joined(separator: " ").replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) + return String((cleaned.isEmpty ? "Untitled" : cleaned).prefix(180)) + } + + private func cleanedTMDBQuery(_ name: String) -> String { parseMediaName(name).title } + private func normalizedFolderName(_ value: String) -> String { value.lowercased().replacingOccurrences(of: "[\\s._()\\[\\]{}-]+", with: "", options: .regularExpression) } - private func enrichPageDetails() async { - let page = files - guard !page.isEmpty else { return } + private func schedulePageDetailEnrichment(force: Bool = false) { + detailEnrichmentTask?.cancel() + detailEnrichmentGeneration = UUID() + let generation = detailEnrichmentGeneration + let page = force ? files : files.filter { $0.size == nil || $0.gcid == nil || $0.modifiedAt.isEmpty } + guard !page.isEmpty else { + isLoadingFolderSizes = false + detailLoadingIDs = [] + sizeLoadingCompleted = 0 + sizeLoadingTotal = 0 + return + } isLoadingFolderSizes = true - defer { isLoadingFolderSizes = false } + detailLoadingIDs = Set(page.map(\.id)) + sizeLoadingCompleted = 0 + sizeLoadingTotal = page.count + detailEnrichmentTask = Task { [weak self] in + await self?.enrichPageDetails(page, generation: generation) + } + } + + private func enrichPageDetails(_ page: [CloudFile], generation: UUID) async { let api = self.api - let details = await withTaskGroup(of: (String, Int64?, String?, Int64?).self, returning: [(String, Int64?, String?, Int64?)].self) { group in - for file in page { + let concurrencyLimit = 6 + var iterator = page.makeIterator() + await withTaskGroup(of: (String, Int64?, String?, Int64?, Int?, Int?).self) { group in + func addNext() { + guard let file = iterator.next() else { return } group.addTask { let detail = try? await api.fsDetail(fileID: file.id) - return (file.id, detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]), detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]), detail?.firstInt64Deep(["utime", "ctime"])) + return ( + file.id, + detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]), + detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]), + detail?.firstInt64Deep(["utime", "ctime"]), + detail?.firstIntDeep(["subDirCount"]), + detail?.firstIntDeep(["subFileCount"]) + ) } } - var result: [(String, Int64?, String?, Int64?)] = [] - for await item in group { result.append(item) } - return result + for _ in 0.. JSONValue { + if !forceRefresh, let cached = detailCache[fileID], cached.expiresAt > Date() { return cached.value } + if let task = detailRequestTasks[fileID] { return try await task.value } + let api = self.api + let gate = detailRequestGate + let task = Task { + await gate.acquire() + defer { Task { await gate.release() } } + try Task.checkCancellation() + return try await api.fsDetail(fileID: fileID) } - files = updatedFiles + detailRequestTasks[fileID] = task + defer { detailRequestTasks[fileID] = nil } + let value = try await task.value + detailCache[fileID] = (value, Date().addingTimeInterval(300)) // 5-minute TTL + if detailCache.count > 800 { detailCache = Dictionary(uniqueKeysWithValues: detailCache.filter { $0.value.expiresAt > Date() }) } + return value } private static func formatDate(_ epoch: Int64) -> String { @@ -421,10 +1109,11 @@ final class AppModel: ObservableObject { return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch))) } - private func extractFiles(from value: JSONValue) -> [CloudFile] { + private nonisolated func extractFiles(from value: JSONValue) -> [CloudFile] { var result: [CloudFile] = [] + var seen = Set() func visit(_ value: JSONValue) { - if let file = CloudFile(json: value), !result.contains(where: { $0.id == file.id }) { result.append(file) } + if let file = CloudFile(json: value), seen.insert(file.id).inserted { result.append(file) } if let object = value.objectValue { object.values.forEach(visit) } if let array = value.arrayValue { array.forEach(visit) } } @@ -449,6 +1138,10 @@ final class AppModel: ObservableObject { self.qrStatus = result.firstStringDeep(["message", "msg", "statusText"]) ?? "等待扫码" } catch let error as GuangyaAPIError where error.isDeviceAuthorizationPending { self.qrStatus = "等待扫码" + } catch let error as GuangyaAPIError where Self.isQRAuthorizationPending(error) { + // The account endpoint can return HTTP 400 "Precondition Required" + // while the device grant is still waiting for the phone confirmation. + self.qrStatus = "已扫码,请在 App 中确认登录" } catch { self.qrStatus = "二维码状态查询失败" self.errorMessage = error.localizedDescription @@ -459,6 +1152,12 @@ final class AppModel: ObservableObject { } } + private static func isQRAuthorizationPending(_ error: GuangyaAPIError) -> Bool { + guard case .http(let status, let message) = error, status == 400 else { return false } + let text = message.lowercased() + return text.contains("precondition required") || text.contains("authorization_pending") || text.contains("slow_down") + } + private func startCountdown() { countdownTask?.cancel() codeCountdown = 60 @@ -472,8 +1171,25 @@ final class AppModel: ObservableObject { private func perform(_ message: String, operation: @escaping @MainActor () async throws -> Void) async { isBusy = true; errorMessage = ""; statusMessage = message - do { try await operation(); if errorMessage.isEmpty { statusMessage = "" } } - catch { errorMessage = error.localizedDescription } + do { + try await operation() + // A request may have silently refreshed the access token. + persistTokens() + if errorMessage.isEmpty { statusMessage = "" } + } catch { + if isAuthorizationExpiry(error) { handleAuthorizationExpiry() } + else if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == NSURLErrorTimedOut { + errorMessage = "网络请求超时:已自动重试。请检查网络或稍后重试。" + } else { errorMessage = error.localizedDescription } + } isBusy = false } } + +private extension String { + var xmlEscaped: String { + replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } +} diff --git a/guangya_mac/ContentView.swift b/guangya_mac/ContentView.swift index fe157ba..e42ce79 100644 --- a/guangya_mac/ContentView.swift +++ b/guangya_mac/ContentView.swift @@ -1,7 +1,9 @@ import AppKit +import AVKit import CoreImage.CIFilterBuiltins import SwiftUI import UniformTypeIdentifiers +import WebKit struct ContentView: View { @StateObject private var model = AppModel() @@ -22,6 +24,43 @@ 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 @@ -53,20 +92,21 @@ private struct LoginView: View { var body: some View { ZStack { - Color(nsColor: .windowBackgroundColor).ignoresSafeArea() + AppBackdrop() HStack(spacing: 0) { LoginBrandPanel() LoginFormPanel(model: model, mode: $mode) } - .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .frame(width: 920, height: 610) + .clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(Color.black.opacity(0.08), lineWidth: 1) + RoundedRectangle(cornerRadius: 34, style: .continuous) + .stroke(.white.opacity(0.52), lineWidth: 1) } - .shadow(color: .black.opacity(0.18), radius: 30, y: 14) - .padding(28) + .shadow(color: .black.opacity(0.22), radius: 42, y: 22) + .padding(30) } - .frame(minWidth: 900, minHeight: 620) + .frame(minWidth: 980, minHeight: 680) .task { guard autoStartQR, model.qrPayload.isEmpty, !model.isBusy else { return } await model.startQRLogin() @@ -76,38 +116,48 @@ private struct LoginView: View { private struct LoginBrandPanel: View { var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 12) { - BrandMark(size: 46) - VStack(alignment: .leading, spacing: 2) { - Text("光鸭云盘").font(.headline) - Text("GUANGYA CLOUD").font(.system(size: 9, weight: .semibold, design: .rounded)).tracking(1.2).foregroundStyle(.white.opacity(0.62)) + ZStack(alignment: .topLeading) { + LinearGradient(colors: [AppTheme.orange, AppTheme.orangeDeep, Color(red: 0.45, green: 0.08, blue: 0.03)], startPoint: .topLeading, endPoint: .bottomTrailing) + Circle().fill(.white.opacity(0.16)).frame(width: 260, height: 260).blur(radius: 10).offset(x: -95, y: -80) + Circle().fill(.yellow.opacity(0.18)).frame(width: 210, height: 210).blur(radius: 16).offset(x: 230, y: 390) + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 13) { + BrandMark(size: 50) + .padding(8) + .background(.white.opacity(0.16), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text("光鸭云盘").font(.title3.weight(.bold)) + Text("GUANGYA CLOUD").font(.system(size: 9, weight: .bold, design: .rounded)).tracking(1.6).foregroundStyle(.white.opacity(0.66)) + } } + Spacer() + Text("文件,") + Text("在你身边。") + Text("轻松管理每一份重要内容。") + .font(.title3.weight(.medium)) + .foregroundStyle(.white.opacity(0.78)) + .padding(.top, 16) + VStack(alignment: .leading, spacing: 15) { + BrandFeature(icon: "square.stack.3d.up.fill", title: "云端文件集中管理") + BrandFeature(icon: "arrow.triangle.2.circlepath", title: "多端同步,随时访问") + BrandFeature(icon: "lock.shield.fill", title: "安全连接,安心使用") + } + .padding(.top, 40) + Spacer() + HStack(spacing: 8) { + Circle().fill(Color.green).frame(width: 8, height: 8).shadow(color: .green.opacity(0.6), radius: 6) + Text("服务正常运行").font(.caption.weight(.medium)).foregroundStyle(.white.opacity(0.74)) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.white.opacity(0.12), in: Capsule()) } - Spacer() - Text("文件,").font(.system(size: 42, weight: .bold, design: .rounded)) - Text("在你身边。").font(.system(size: 42, weight: .bold, design: .rounded)) - Text("轻松管理每一份重要内容。") - .font(.title3) - .foregroundStyle(.white.opacity(0.76)) - .padding(.top, 14) - VStack(alignment: .leading, spacing: 14) { - BrandFeature(icon: "square.stack.3d.up.fill", title: "云端文件集中管理") - BrandFeature(icon: "arrow.triangle.2.circlepath", title: "随时访问你的内容") - BrandFeature(icon: "lock.shield.fill", title: "安全连接,安心使用") - } - .padding(.top, 38) - Spacer() - HStack(spacing: 7) { - Circle().fill(Color.green).frame(width: 7, height: 7) - Text("服务正常运行").font(.caption).foregroundStyle(.white.opacity(0.72)) - } + .font(.system(size: 44, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .padding(42) } - .foregroundStyle(.white) - .padding(42) - .frame(width: 370) + .frame(width: 380) .frame(maxHeight: .infinity, alignment: .leading) - .background(Color(red: 0.95, green: 0.30, blue: 0.055)) } } @@ -116,8 +166,11 @@ private struct BrandFeature: View { let title: String var body: some View { HStack(spacing: 12) { - Image(systemName: icon).font(.callout.weight(.semibold)).frame(width: 20) - Text(title).font(.callout).foregroundStyle(.white.opacity(0.86)) + Image(systemName: icon) + .font(.callout.weight(.semibold)) + .frame(width: 30, height: 30) + .background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + Text(title).font(.callout.weight(.medium)).foregroundStyle(.white.opacity(0.88)) } } } @@ -140,11 +193,12 @@ private struct LoginFormPanel: View { .font(.title2) .foregroundStyle(Color.orange) } - Picker("登录方式", selection: $mode) { - Label("二维码登录", systemImage: "qrcode").tag(0) - Label("手机号登录", systemImage: "iphone").tag(1) + HStack(spacing: 4) { + loginModeButton(title: "扫码登录", icon: "qrcode", tag: 0) + loginModeButton(title: "手机号", icon: "iphone", tag: 1) } - .pickerStyle(.segmented) + .padding(4) + .background(Color.black.opacity(0.055), in: Capsule()) Group { if mode == 0 { QRLoginPanel(model: model) @@ -154,13 +208,9 @@ private struct LoginFormPanel: View { } .frame(maxWidth: .infinity) } - .padding(32) - .frame(width: 450) - .liquidGlass(cornerRadius: 18) - .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.black.opacity(0.06), lineWidth: 1) - } + .padding(34) + .frame(width: 452) + .softCard(radius: 24) Spacer() HStack(spacing: 5) { Image(systemName: "checkmark.seal.fill") @@ -171,7 +221,20 @@ private struct LoginFormPanel: View { .padding(.bottom, 24) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .underPageBackgroundColor)) + .background(Color.clear) + } + + private func loginModeButton(title: String, icon: String, tag: Int) -> some View { + Button { withAnimation(.easeInOut(duration: 0.18)) { mode = tag } } label: { + Label(title, systemImage: icon) + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .foregroundStyle(mode == tag ? Color.primary : Color.secondary) + .background(mode == tag ? Color(nsColor: .windowBackgroundColor) : Color.clear, in: Capsule()) + .shadow(color: mode == tag ? .black.opacity(0.08) : .clear, radius: 3, y: 1) + } + .buttonStyle(.plain) } } @@ -206,21 +269,21 @@ private struct QRLoginPanel: View { Circle().fill(model.qrPayload.isEmpty ? Color.secondary : Color.green).frame(width: 7, height: 7) Text(model.qrStatus).font(.callout).foregroundStyle(.secondary) } - Button { - Task { await model.startQRLogin() } - } label: { - HStack(spacing: 8) { + HStack(spacing: 8) { + Text("请使用光鸭 App 扫码登录") + .font(.caption) + .foregroundStyle(.tertiary) + Spacer() + Button { Task { await model.startQRLogin() } } label: { if model.isBusy { ProgressView().controlSize(.small) } - Image(systemName: model.qrPayload.isEmpty ? "qrcode" : "arrow.clockwise") - Text(model.qrPayload.isEmpty ? "生成登录二维码" : "刷新二维码") + else { Image(systemName: "arrow.clockwise") } } - .frame(maxWidth: .infinity) + .buttonStyle(.bordered) + .controlSize(.small) + .help("刷新二维码") + .disabled(model.isBusy) } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(Color.orange) - .disabled(model.isBusy) - Text("请使用光鸭 App 扫码登录") + Text("二维码会在打开登录页时自动生成") .font(.caption) .foregroundStyle(.tertiary) } @@ -295,30 +358,63 @@ 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 selectedFileID: String? + @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? { model.files.first { $0.id == selectedFileID } } + private var selectedFile: CloudFile? { selectedFileIDs.count == 1 ? model.files.first { $0.id == selectedFileIDs.first } : nil } var body: some View { - NavigationSplitView { - WorkspaceSidebar(model: model, isShowingTools: $isShowingTools) - } content: { - FilesBrowser(model: model, selectedFileID: $selectedFileID, renameFile: $renameFile, isCreatingFolder: $isCreatingFolder, isImporting: $isImporting, isShowingSettings: $isShowingSettings, isShowingDuplicates: $isShowingDuplicates, folderName: $folderName) - } detail: { - FileInspector(model: model, file: selectedFile) + 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) } } } - .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 270) - .frame(minWidth: 1180, minHeight: 700) - .background(Color(nsColor: .underPageBackgroundColor)) + .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 @@ -330,135 +426,456 @@ private struct WorkspaceView: View { .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) } - .onChange(of: model.section) { _, _ in Task { await model.refresh() } } + .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 { @ObservedObject var model: AppModel - @Binding var isShowingTools: Bool + @Binding var selectedTool: WorkspaceTool? + @Binding var columnVisibility: NavigationSplitViewVisibility + @State private var toolsExpanded = true + @State private var categoriesExpanded = false + var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 10) { - BrandMark(size: 32) - VStack(alignment: .leading, spacing: 1) { Text("光鸭云盘").font(.headline); Text("CLOUD WORKSPACE").font(.system(size: 8, weight: .semibold, design: .rounded)).tracking(1).foregroundStyle(.secondary) } + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 12) { + BrandMark(size: 34) + .padding(7) + .background(Color.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text("光鸭云盘").font(.headline.weight(.bold)) + Text("Cloud Workspace").font(.system(size: 9, weight: .bold, design: .rounded)).tracking(1.1).foregroundStyle(.secondary) + } Spacer() - }.padding(.horizontal, 18).padding(.vertical, 20) - List(selection: $model.section) { - Section("文件空间") { ForEach([WorkspaceSection.files, .photos, .videos, .documents]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } } - Section("工作区") { ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } } - }.listStyle(.sidebar).scrollContentBackground(.hidden) - Divider() - VStack(alignment: .leading, spacing: 8) { - Button { isShowingTools = true } label: { - Label("工具", systemImage: "slider.horizontal.3") - .font(.caption.weight(.semibold)) - .frame(maxWidth: .infinity, alignment: .leading) - }.buttonStyle(.plain) - Text("清理、去重、相似文件夹和工作区设置").font(.caption2).foregroundStyle(.tertiary).fixedSize(horizontal: false, vertical: true) - }.padding(.horizontal, 18).padding(.vertical, 14) - Divider() - HStack(spacing: 10) { - Circle().fill(Color.orange.opacity(0.18)).frame(width: 32, height: 32).overlay { Text(String(model.user.name.prefix(1))).font(.headline).foregroundStyle(.orange) } - VStack(alignment: .leading, spacing: 2) { Text(model.user.name).font(.callout.weight(.medium)).lineLimit(1); Text(model.user.phone).font(.caption).foregroundStyle(.secondary).lineLimit(1) } + } + .padding(.top, 8) + + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + Text("文件空间").font(.caption.weight(.bold)).foregroundStyle(.secondary).padding(.horizontal, 8) + ForEach([WorkspaceSection.files, .recentViewed, .recentRestored]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } } + } + VStack(alignment: .leading, spacing: 8) { + Text("工作区").font(.caption.weight(.bold)).foregroundStyle(.secondary).padding(.horizontal, 8) + ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in + SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } + } + } + + VStack(spacing: 5) { + Button { withAnimation(.easeInOut(duration: 0.18)) { categoriesExpanded.toggle() } } label: { HStack { Image(systemName: "square.grid.2x2").foregroundStyle(.orange).frame(width: 26); Text("文件分类").font(.callout.weight(.semibold)); Spacer(); Image(systemName: "chevron.right").font(.caption.weight(.bold)).rotationEffect(.degrees(categoriesExpanded ? 90 : 0)) }.frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()).padding(10).background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 13)) }.buttonStyle(.plain) + if categoriesExpanded { VStack(spacing: 4) { ForEach([WorkspaceSection.photos, .videos, .audio, .documents]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } } }.transition(.opacity.combined(with: .move(edge: .top))) } + } + + VStack(spacing: 6) { + Button { withAnimation(.easeInOut(duration: 0.18)) { toolsExpanded.toggle() } } label: { + HStack(spacing: 10) { + Image(systemName: "shippingbox.fill").foregroundStyle(.orange).frame(width: 26) + VStack(alignment: .leading, spacing: 2) { + Text("工具箱").font(.callout.weight(.semibold)) + Text("独立工具页面").font(.caption2).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.down").font(.caption.weight(.bold)).foregroundStyle(.tertiary).rotationEffect(.degrees(toolsExpanded ? 0 : -90)) + } + .padding(12) + .background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + } + .buttonStyle(.plain) + + if toolsExpanded { + VStack(spacing: 4) { + ForEach(WorkspaceTool.allCases) { tool in + ToolSidebarItem(tool: tool, selected: selectedTool == tool) { selectedTool = tool } + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + .padding(.trailing, 4) + } + .frame(maxHeight: .infinity) + .clipped() + + HStack { + Spacer() + Button { withAnimation(.easeInOut(duration: 0.2)) { columnVisibility = columnVisibility == .detailOnly ? .all : .detailOnly } } label: { Image(systemName: "sidebar.left") } + .buttonStyle(.bordered).controlSize(.small).help("显示或隐藏侧边栏") + } + + HStack(spacing: 11) { + Menu { + Text("会员:\(model.user.memberLevel)") + Text("账号:\(model.user.phone.isEmpty ? "未提供" : model.user.phone)") + Text("空间:\(model.user.capacityText)") + Divider() + Button("退出登录", role: .destructive) { model.logout() } + } label: { + HStack(spacing: 11) { + Circle().fill(Color.orange.opacity(0.18)).frame(width: 38, height: 38).overlay { Text(String(model.user.name.prefix(1))).font(.headline.weight(.bold)).foregroundStyle(.orange) } + VStack(alignment: .leading, spacing: 2) { Text(model.user.name).font(.callout.weight(.semibold)).lineLimit(1); Text(model.user.memberLevel).font(.caption).foregroundStyle(.secondary).lineLimit(1) } + } + .contentShape(Rectangle()) + }.menuStyle(.borderlessButton).help("查看账户与空间信息") Spacer() Button { model.logout() } label: { Image(systemName: "rectangle.portrait.and.arrow.right") }.buttonStyle(.plain).help("退出登录") - }.padding(14) + } + .padding(12) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } - .padding(8) - .liquidGlass(cornerRadius: 18) - .padding(8) + .padding(16) + .softCard(radius: 24) + .padding(10) + } +} + +private struct SidebarItem: View { + let section: WorkspaceSection + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 11) { + Image(systemName: section.icon) + .font(.system(size: 15, weight: .semibold)) + .frame(width: 28, height: 28) + .foregroundStyle(selected ? .white : .orange) + .background(selected ? Color.orange : Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + Text(section.rawValue).font(.callout.weight(selected ? .semibold : .regular)) + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .foregroundStyle(selected ? .primary : .secondary) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(selected ? Color.orange.opacity(0.14) : Color.clear, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .focusable(false) + } +} + +private struct ToolSidebarItem: View { + let tool: WorkspaceTool + let selected: Bool + let action: () -> Void + var body: some View { + Button(action: action) { + HStack(spacing: 9) { + Image(systemName: tool.icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(selected ? .white : .orange) + .frame(width: 26, height: 26) + .background(selected ? Color.orange : Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + Text(tool.rawValue).font(.caption.weight(selected ? .semibold : .regular)).lineLimit(1) + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(selected ? Color.orange.opacity(0.13) : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .focusable(false) } } private struct FilesBrowser: View { @ObservedObject var model: AppModel - @Binding var selectedFileID: String? + @Binding var selectedFileIDs: Set + @Binding var globalSearchText: String @Binding var renameFile: CloudFile? @Binding var isCreatingFolder: Bool @Binding var isImporting: Bool @Binding var isShowingSettings: Bool @Binding var isShowingDuplicates: Bool @Binding var folderName: String + let onShowDetails: (CloudFile) -> Void + let onPreview: (CloudFile) -> Void @State private var searchText = "" - private var filteredFiles: [CloudFile] { searchText.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(searchText) } } + @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) } + } + private var fileCount: Int { model.files.filter { !$0.isDirectory }.count } + private var folderCount: Int { model.files.filter(\.isDirectory).count } + private var totalSizeText: String { + let total = model.files.compactMap(\.size).reduce(Int64(0), +) + guard total > 0 else { return "--" } + let formatter = ByteCountFormatter(); formatter.countStyle = .file + return formatter.string(fromByteCount: total) + } var body: some View { - VStack(spacing: 0) { - HStack(alignment: .top, spacing: 14) { - VStack(alignment: .leading, spacing: 6) { - Text(model.section.rawValue).font(.system(size: 24, weight: .bold, design: .rounded)) - if model.section == .files && !model.folderPath.isEmpty { - HStack(spacing: 5) { - Button("全部文件") { Task { await model.navigateToRoot() } }.buttonStyle(.link) - ForEach(Array(model.folderPath.enumerated()), id: \.element.id) { index, folder in - Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary) - Button(folder.name) { Task { await model.navigateToFolder(at: index) } }.buttonStyle(.link).lineLimit(1) - } - }.font(.caption) - } else { - Text(model.section == .files ? "全部文件" : "按类型整理的文件").font(.subheadline).foregroundStyle(.secondary) - } - } - Spacer() - HStack(spacing: 8) { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("搜索文件", text: $searchText).textFieldStyle(.plain).frame(width: 130) - }.padding(.horizontal, 11).padding(.vertical, 9).liquidGlass(cornerRadius: 11) - Button { isImporting = true } label: { Label("上传", systemImage: "arrow.up.circle") }.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("刷新").disabled(model.isBusy || model.isLoadingFiles) - Menu { - Button { Task { await model.cleanEmptyFolders() } } label: { Label("清理空文件夹", systemImage: "sparkles") } - Button { Task { await model.scanDuplicates(); isShowingDuplicates = true } } label: { Label("扫描重复文件", systemImage: "square.on.square") } - Button { Task { await model.scanDuplicates(excludingSmallFiles: true); isShowingDuplicates = true } } label: { Label("扫描重复文件(排除小文件)", systemImage: "square.on.square.fill") } - Button { model.findSimilarFolders() } label: { Label("查询相似文件夹", systemImage: "rectangle.3.group") } - Divider() - Button { isShowingSettings = true } label: { Label("工作区设置", systemImage: "gearshape") } - } label: { Image(systemName: "ellipsis.circle") }.menuStyle(.borderlessButton).help("更多工具") + VStack(spacing: 12) { + browserHeader + content + statusBar + } + .padding(16) + .background(Color.clear) + .onMoveCommand { direction in moveKeyboardSelection(direction: direction) } + .onCommand(#selector(NSResponder.scrollPageUp(_:))) { moveKeyboardSelection(page: -1) } + .onCommand(#selector(NSResponder.scrollPageDown(_:))) { moveKeyboardSelection(page: 1) } + .onCommand(#selector(NSResponder.moveToBeginningOfDocument(_:))) { selectKeyboardIndex(0) } + .onCommand(#selector(NSResponder.moveToEndOfDocument(_:))) { selectKeyboardIndex(max(0, filteredFiles.count - 1)) } + .onCommand(#selector(NSResponder.deleteBackward(_:))) { if !selectedFileIDs.isEmpty { showBatchDeleteConfirmation = true } } + .sheet(item: $tmdbTarget) { TMDBRecognizeSheet(model: model, file: $0) } + .confirmationDialog("确认删除已选 \(selectedFileIDs.count) 项?", isPresented: $showBatchDeleteConfirmation) { + Button("删除 \(selectedFileIDs.count) 项", role: .destructive) { + let ids = selectedFileIDs + selectedFileIDs = [] + selectionAnchorID = nil + Task { await model.deleteSelectedFiles(ids) } } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .liquidGlass(cornerRadius: 16) - .padding(.horizontal, 12) - .padding(.top, 14) - .padding(.bottom, 12) - if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section) } - else if model.isLoadingFiles && model.files.isEmpty { VStack(spacing: 12) { ProgressView(); Text("正在加载文件…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } - else if filteredFiles.isEmpty { EmptyFilesView(model: model) } - else { - List(selection: $selectedFileID) { - ForEach(filteredFiles) { file in - BrowserRow(file: file) { - selectedFileID = file.id - Task { await model.open(file) } - } onSelect: { selectedFileID = file.id } - .tag(file.id) - .contextMenu { fileMenu(file) } - .draggable(file.id) - .dropDestination(for: String.self) { values, _ in - guard file.isDirectory else { return false } - let ids = values.filter { $0 != file.id } - guard !ids.isEmpty else { return false } - Task { await model.move(fileIDs: ids, to: file.id) } - return true - } + Button("取消", role: .cancel) { } + } message: { Text("选中的文件及文件夹将被删除。文件夹会连同其内容一起删除,且此操作不可撤销。") } + .confirmationDialog( + pendingDelete?.isDirectory == true ? "确认删除文件夹?" : "确认删除文件?", + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + presenting: pendingDelete + ) { file in + Button("删除", role: .destructive) { + pendingDelete = nil + selectedFileIDs.remove(file.id) + Task { await model.delete(file) } + } + Button("取消", role: .cancel) { pendingDelete = nil } + } message: { file in + Text(file.isDirectory ? "“\(file.name)”及其包含的内容将被删除。删除成功后会直接从当前列表移除。" : "“\(file.name)”将被删除。删除成功后会直接从当前列表移除。") + } + } + + private var browserHeader: some View { + VStack(spacing: 9) { + HStack { breadcrumb; Spacer(minLength: 0) } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4).padding(.vertical, 2) + } + + private var breadcrumb: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 7) { + Button { Task { await model.navigateToRoot() } } label: { + Image(systemName: "house.fill").font(.system(size: 11, weight: .bold)) + } + .buttonStyle(.plain).foregroundStyle(.orange) + if model.section == .files && !model.folderPath.isEmpty { + ForEach(Array(model.folderPath.enumerated()), id: \.element.id) { index, folder in + Image(systemName: "chevron.right").font(.system(size: 8, weight: .bold)).foregroundStyle(.tertiary) + Button(folder.name) { Task { await model.navigateToFolder(at: index) } } + .buttonStyle(.plain) + .font(.caption.weight(index == model.folderPath.count - 1 ? .bold : .medium)) + .foregroundStyle(index == model.folderPath.count - 1 ? .primary : .secondary) + .lineLimit(1) + .padding(.horizontal, 8).padding(.vertical, 5) + .background(index == model.folderPath.count - 1 ? Color.orange.opacity(0.12) : Color.clear, in: Capsule()) } - }.listStyle(.inset(alternatesRowBackgrounds: true)) + } else { + Text(model.section == .files ? "全部文件" : "按类型整理的文件").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10).padding(.vertical, 5) + .background(.thinMaterial, in: Capsule()) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var statusBar: some View { + HStack(spacing: 12) { + Label("文件 \(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 + } } } - .background(Color(nsColor: .underPageBackgroundColor)) + .font(.caption) + .padding(.horizontal, 12).padding(.vertical, 8) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(.white.opacity(0.35), lineWidth: 1) } + } + + private var 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 filteredFiles.isEmpty { EmptyFilesView(model: model).softCard(radius: 24).padding(.horizontal, 10) } + else { + List(selection: $selectedFileIDs) { + Section { + ForEach(filteredFiles) { file in + BrowserRow(file: file, isSelected: selectedFileIDs.contains(file.id), isLoadingDetails: model.detailLoadingIDs.contains(file.id), isActionLoading: model.actionLoadingIDs.contains(file.id)) { modifiers in + select(file, modifiers: modifiers) + } onOpen: { + selectedFileIDs = [file.id] + if file.isDirectory { Task { await model.openFolder(file) } } else { onPreview(file) } + } + .tag(file.id) + .onAppear { model.loadVisibleItemDetails(file) } + .contextMenu { fileMenu(file) } + .draggable(file.id) + .dropDestination(for: String.self) { values, _ in + guard file.isDirectory else { return false } + let ids = values.filter { $0 != file.id } + guard !ids.isEmpty else { return false } + Task { await model.move(fileIDs: ids, to: file.id) } + return true + } + } + } header: { + HStack { + sortHeader("名称", key: .name, alignment: .leading).frame(maxWidth: .infinity, alignment: .leading) + sortHeader("大小", key: .size, alignment: .trailing).frame(width: 96, alignment: .trailing) + sortHeader("修改时间", key: .modifiedAt, alignment: .trailing).frame(width: 132, alignment: .trailing) + Image(systemName: "chevron.right").opacity(0).frame(width: 18) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(.white.opacity(0.42), lineWidth: 1) } + } + } + } + + private func moveKeyboardSelection(direction: MoveCommandDirection) { + moveKeyboardSelection(page: direction == .up || direction == .left ? -1 : 1) + } + + private func moveKeyboardSelection(page: Int) { + guard !filteredFiles.isEmpty else { return } + let step = abs(page) == 1 ? 1 : 12 + let current = keyboardFocusID.flatMap { id in filteredFiles.firstIndex { $0.id == id } } ?? selectedFileIDs.first.flatMap { id in filteredFiles.firstIndex { $0.id == id } } ?? (page > 0 ? -1 : filteredFiles.count) + selectKeyboardIndex(max(0, min(filteredFiles.count - 1, current + (page > 0 ? step : -step)))) + } + + private func selectKeyboardIndex(_ index: Int) { + guard filteredFiles.indices.contains(index) else { return } + let file = filteredFiles[index] + keyboardFocusID = file.id; selectionAnchorID = file.id; selectedFileIDs = [file.id] + } + + private func select(_ file: CloudFile, modifiers: NSEvent.ModifierFlags) { + keyboardFocusID = file.id + if modifiers.contains(.shift), let anchor = selectionAnchorID, + let start = filteredFiles.firstIndex(where: { $0.id == anchor }), + let end = filteredFiles.firstIndex(where: { $0.id == file.id }) { + selectedFileIDs.formUnion(filteredFiles[min(start, end)...max(start, end)].map(\.id)) + } else if modifiers.contains(.command) { + if selectedFileIDs.contains(file.id) { selectedFileIDs.remove(file.id) } + else { selectedFileIDs.insert(file.id) } + selectionAnchorID = file.id + } else { + selectedFileIDs = [file.id] + selectionAnchorID = file.id + } + } + + private func sortHeader(_ title: String, key: FileSort, alignment: Alignment) -> some View { + Button { Task { await model.setServerSort(key) } } label: { + HStack(spacing: 3) { + Text(title) + if model.serverSort == key { Image(systemName: model.serverSortDirection == .ascending ? "chevron.up" : "chevron.down").font(.system(size: 9, weight: .bold)) } + } + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: alignment) + .help("按\(title)进行服务端排序") + } + + private var paginationControls: some View { + HStack(spacing: 10) { + Picker("每页", selection: Binding(get: { model.pageSize }, set: { size in Task { await model.setPageSize(size) } })) { + Text("50 / 页").tag(50); Text("100 / 页").tag(100); Text("200 / 页").tag(200); Text("500 / 页").tag(500); Text("1000 / 页").tag(1000); Text("2000 / 页").tag(2000); Text("5000 / 页").tag(5000); Text("10000 / 页").tag(10000) + } + .labelsHidden().frame(width: 92) + Button { Task { await model.goToPage(model.currentPage - 1) } } label: { Image(systemName: "chevron.left") } + .buttonStyle(.bordered).disabled(model.currentPage == 0 || model.isLoadingFiles) + Text("\(model.currentPage + 1) / \(model.totalPages)").font(.callout.monospacedDigit()).frame(minWidth: 62) + Button { Task { await model.goToPage(model.currentPage + 1) } } label: { Image(systemName: "chevron.right") } + .buttonStyle(.bordered).disabled(model.currentPage + 1 >= model.totalPages || model.isLoadingFiles) + } + } + + private var moreMenu: some View { + Menu { + Button { model.reloadCurrentListSizes() } label: { Label("重新获取当前列表大小", systemImage: "arrow.triangle.2.circlepath") } + .disabled(model.isLoadingFolderSizes || model.files.isEmpty) + Divider() + Button { Task { await model.cleanEmptyFolders() } } label: { Label("清理空文件夹", systemImage: "sparkles") } + Button { Task { await model.scanDuplicates(); isShowingDuplicates = true } } label: { Label("扫描重复文件", systemImage: "square.on.square") } + Button { Task { await model.scanDuplicates(excludingSmallFiles: true); isShowingDuplicates = true } } label: { Label("扫描重复文件(排除小文件)", systemImage: "square.on.square.fill") } + Button { model.findSimilarFolders() } label: { Label("查询相似文件夹", systemImage: "rectangle.3.group") } + Divider() + Button { isShowingSettings = true } label: { Label("工作区设置", systemImage: "gearshape") } + } label: { Image(systemName: "ellipsis.circle.fill").font(.title3).foregroundStyle(.secondary) } + .menuStyle(.borderlessButton) + .help("更多工具") } @ViewBuilder private func fileMenu(_ file: CloudFile) -> some View { - Button { Task { await model.open(file) } } label: { Label("打开", systemImage: "arrow.up.right.square") } + 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 { Task { await model.share(file) } } label: { Label("分享", systemImage: "square.and.arrow.up") } Menu("复制到") { Button("当前文件夹") { Task { await model.copy(file, to: model.folderPath.last?.id) } } @@ -470,80 +887,629 @@ private struct FilesBrowser: View { } Divider() Button { renameFile = file } label: { Label("重命名", systemImage: "pencil") } - Button { Task { await model.showDetails(file) } } label: { Label("查看详情", systemImage: "info.circle") } if file.isDirectory { - Button { Task { await model.recognizeTMDBFolder(file) } } label: { Label("TMDB 识别当前文件夹及子文件夹", systemImage: "film") } + 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 { tmdbTarget = file } label: { Label("TMDB 识别与刮削…", systemImage: "film") } } else { - Button { Task { await model.recognizeTMDB(file) } } label: { Label("TMDB 识别", systemImage: "film") } - Button { Task { await model.recognizeTMDB(file, organize: true) } } label: { Label("TMDB 识别并整理", systemImage: "film.stack") } + Button { tmdbTarget = file } label: { Label("TMDB 识别与刮削…", systemImage: "film") } } Button { model.copyTransferJSON(for: file) } label: { Label("复制秒传 JSON", systemImage: "doc.on.doc") } Divider() if model.section == .recycle { Button { Task { await model.restore(file) } } label: { Label("恢复", systemImage: "arrow.uturn.backward") } } - Button(role: .destructive) { Task { await model.delete(file) } } label: { Label("删除", systemImage: "trash") } + 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) } + } + .padding(.horizontal, 12).padding(.vertical, 10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 15, style: .continuous)) } } private struct BrowserRow: View { let file: CloudFile + let isSelected: Bool + let isLoadingDetails: Bool + let isActionLoading: Bool + let onSelect: (NSEvent.ModifierFlags) -> Void let onOpen: () -> Void - let onSelect: () -> Void + var body: some View { - Button(action: file.isDirectory ? onOpen : onSelect) { + Button(action: { onSelect(NSEvent.modifierFlags) }) { HStack(spacing: 12) { - Image(systemName: file.icon).font(.title3).foregroundStyle(file.isDirectory ? .orange : .accentColor).frame(width: 28) - VStack(alignment: .leading, spacing: 3) { - Text(file.name).font(.body).lineLimit(1) - HStack(spacing: 6) { Text(file.typeName); if file.isDirectory && file.size != nil { Text("·"); Text(file.formattedSize) } }.font(.caption).foregroundStyle(.secondary) + Image(systemName: file.icon) + .font(.title3.weight(.semibold)) + .foregroundStyle(file.isDirectory ? .orange : .accentColor) + .frame(width: 34, height: 34) + .background((file.isDirectory ? Color.orange : Color.accentColor).opacity(0.11), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) + VStack(alignment: .leading, spacing: 4) { + Text(file.name).font(.body.weight(.medium)).lineLimit(1) + HStack(spacing: 6) { + Text(file.typeName) + if isLoadingDetails { Text("·"); Text("正在获取详情") } + else if file.isDirectory, let folders = file.subDirectoryCount, let files = file.subFileCount { Text("·"); Text("\(folders) 个文件夹,\(files) 个文件") } + else if file.gcid != nil { Text("·"); Text("GCID 已获取") } + }.font(.caption).foregroundStyle(.secondary) } Spacer() - Text(file.formattedSize).font(.callout).foregroundStyle(.secondary).frame(width: 90, alignment: .trailing) - Text(file.modifiedAt.isEmpty ? "--" : file.modifiedAt).font(.callout).foregroundStyle(.secondary).frame(width: 130, alignment: .trailing) - if file.isDirectory { Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) } - }.padding(.vertical, 8).frame(maxWidth: .infinity, alignment: .leading) - }.buttonStyle(.plain) + Group { + if isActionLoading { ProgressView().controlSize(.small).help("正在处理…") } + else if isLoadingDetails && file.size == nil { ProgressView().controlSize(.small) } + else { Text(file.formattedSize).font(.callout.monospacedDigit()).foregroundStyle(.secondary) } + }.frame(width: 96, alignment: .trailing) + Text(file.modifiedAt.isEmpty ? "--" : file.modifiedAt).font(.callout.monospacedDigit()).foregroundStyle(.secondary).frame(width: 132, alignment: .trailing) + Image(systemName: file.isDirectory ? "chevron.right" : "info.circle").font(.caption.weight(.bold)).foregroundStyle(.tertiary).frame(width: 18) + } + .padding(.vertical, 8) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isSelected ? Color.orange.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(file.name) + .accessibilityValue(isSelected ? "已选中" : "未选中") + .accessibilityHint(file.isDirectory ? "双击打开文件夹" : "双击预览文件") + .accessibilityAddTraits(isSelected ? .isSelected : []) + .disabled(isActionLoading) + .opacity(isActionLoading ? 0.55 : 1) + .simultaneousGesture(TapGesture(count: 2).onEnded(onOpen)) } } +private struct TMDBRecognizeSheet: View { + @ObservedObject var model: AppModel + let file: CloudFile + @Environment(\.dismiss) private var dismiss + @State private var tmdbID = "" + @State private var mediaKind: TMDBMediaKind = .automatic + @State private var chineseTitle = "" + @State private var englishTitle = "" + @State private var yearText = "" + @State private var hasQueried = false + + private var parsed: ParsedMediaName { model.parsedTMDBInfo(for: file) } + private var queryJob: TMDBJob? { model.tmdbJobs.first(where: { $0.id == file.id }) } + private var queryTitle: String { chineseTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? englishTitle : chineseTitle } + var body: some View { + VStack(alignment: .leading, spacing: 18) { + SheetHeader(icon: "film.stack", title: "TMDB 识别与刮削", subtitle: file.name) + VStack(alignment: .leading, spacing: 8) { + Text("可编辑识别信息").font(.caption.weight(.bold)).foregroundStyle(.secondary) + HStack { TextField("中文标题", text: $chineseTitle).textFieldStyle(.roundedBorder); TextField("英文标题", text: $englishTitle).textFieldStyle(.roundedBorder); TextField("年份", text: $yearText).textFieldStyle(.roundedBorder).frame(width: 90) } + } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + InspectorField(title: "自动解析标题", value: parsed.title) + InspectorField(title: "自动解析年份", value: parsed.year.map(String.init) ?? "--") + InspectorField(title: "集数", value: parsed.isEpisode ? "S\(String(format: "%02d", parsed.season ?? 0))E\(String(format: "%02d", parsed.episode ?? 0))" : "电影/整季") + InspectorField(title: "介质", value: parsed.isDiscStructure ? "ISO / BDMV / 光盘结构" : "普通媒体文件") + InspectorField(title: "分辨率", value: parsed.resolution ?? "--") + InspectorField(title: "来源 / 编码", value: [parsed.source, parsed.videoCodec].compactMap { $0 }.joined(separator: " · ").isEmpty ? "--" : [parsed.source, parsed.videoCodec].compactMap { $0 }.joined(separator: " · ")) + InspectorField(title: "音频", value: parsed.audio ?? "--") + } + Picker("媒体类型", selection: $mediaKind) { ForEach(TMDBMediaKind.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) + HStack { TextField("手动 TMDB ID", text: $tmdbID).textFieldStyle(.roundedBorder); Button("按 ID 建立刮削任务") { if let id = Int(tmdbID) { Task { await model.recognizeTMDBByID(file, tmdbID: id, mediaKind: mediaKind); dismiss() } } }.buttonStyle(.bordered) } + if hasQueried, let job = queryJob { + Divider() + Text("TMDB 候选结果").font(.headline) + if job.candidates.isEmpty { ContentUnavailableView("未找到可用结果", systemImage: "magnifyingglass", description: Text(job.note)) } + else { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(job.candidates) { candidate in Button { model.selectTMDBCandidate(jobID: job.id, candidateID: candidate.id) } label: { HStack(alignment: .top, spacing: 10) { Image(systemName: candidate.mediaType == .tv ? "tv" : "film").foregroundStyle(.purple).frame(width: 24); VStack(alignment: .leading, spacing: 3) { Text("\(candidate.title) \(candidate.releaseDate.prefix(4))").font(.callout.weight(.semibold)); if candidate.originalTitle != candidate.title { Text(candidate.originalTitle).font(.caption).foregroundStyle(.secondary) }; Text(candidate.overview.isEmpty ? "暂无简介" : candidate.overview).font(.caption).foregroundStyle(.secondary).lineLimit(2) }; Spacer(); if job.selectedCandidateID == candidate.id { Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) } } .padding(10).frame(maxWidth: .infinity, alignment: .leading).background(job.selectedCandidateID == candidate.id ? Color.purple.opacity(0.12) : Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) }.buttonStyle(.plain) } }.padding(.vertical, 2) }.frame(maxHeight: 240) } + if job.selectedCandidate != nil { Button { model.toggleTMDBApproval(jobID: job.id); dismiss() } label: { Label("使用已选候选建立任务", systemImage: "checkmark.circle") }.buttonStyle(.borderedProminent).tint(.purple) } + } + HStack { Spacer(); Button("取消") { dismiss() }; Button { Task { await model.queryTMDBManually(for: file, title: queryTitle, year: Int(yearText), mediaKind: mediaKind); hasQueried = true } } label: { if model.isRunningTMDBJob { ProgressView().controlSize(.small) }; Label(model.isRunningTMDBJob ? "正在查询…" : (hasQueried ? "重新查询" : "查询 TMDB 候选"), systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).tint(.purple).disabled(model.isRunningTMDBJob || queryTitle.isEmpty) } + }.padding(22).frame(minWidth: 620) + .onAppear { chineseTitle = parsed.title; englishTitle = ""; yearText = parsed.year.map(String.init) ?? "" } + } +} + +private struct FileDetailsSheet: View { + @ObservedObject var model: AppModel + let file: CloudFile + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 14) { + Image(systemName: file.icon).font(.title2.weight(.semibold)).foregroundStyle(file.isDirectory ? .orange : .blue) + .frame(width: 48, height: 48).background((file.isDirectory ? Color.orange : Color.blue).opacity(0.12), in: RoundedRectangle(cornerRadius: 15)) + VStack(alignment: .leading, spacing: 3) { Text(file.name).font(.title3.weight(.bold)).lineLimit(2); Text("文件详情").font(.caption).foregroundStyle(.secondary) } + Spacer() + if model.isBusy { ProgressView().controlSize(.small) } + Button("完成") { dismiss() }.buttonStyle(.borderedProminent).tint(.orange) + }.padding(20) + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 14) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + InspectorField(title: "文件 ID", value: file.id) + InspectorField(title: "类型", value: file.typeName) + InspectorField(title: "大小", value: file.formattedSize) + InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt) + } + if model.detailFileID == file.id, let detail = model.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("未能读取服务器详情")) + } + }.padding(20) + } + } + .frame(minWidth: 620, minHeight: 520) + .task(id: file.id) { await model.showDetails(file) } + } +} + +private struct FilePreviewSheet: View { + @ObservedObject var model: AppModel + let file: CloudFile + @Environment(\.dismiss) private var dismiss + @State private var url: URL? + @State private var errorText = "" + + private var ext: String { (file.name as NSString).pathExtension.lowercased() } + private var isImage: Bool { file.fileType == 1 || ["jpg", "jpeg", "png", "gif", "webp", "heic", "bmp", "tiff"].contains(ext) } + // AVPlayer handles the native family; containers/codecs beyond it can be handed + // to IINA/VLC, which are purpose-built for MKV, HEVC, AV1, DTS, subtitles, etc. + private var isVideo: Bool { file.fileType == 2 || ["mp4", "mov", "m4v", "mkv", "webm", "avi", "flv", "wmv", "ts", "mts", "m2ts", "mpeg", "mpg", "3gp", "ogv", "vob", "rmvb", "asf", "f4v"].contains(ext) } + private var nativeVideo: Bool { ["mp4", "mov", "m4v"].contains(ext) } + + var body: some View { + VStack(spacing: 0) { + HStack { Image(systemName: file.icon).foregroundStyle(.orange); Text(file.name).font(.headline).lineLimit(1); Spacer(); if isVideo { Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("使用 IINA / VLC", systemImage: "play.rectangle.on.rectangle") }.buttonStyle(.bordered).help("适用于 MKV、AV1、HEVC、DTS、外挂字幕等格式") }; Button("关闭 (Esc)") { dismiss() }.buttonStyle(.bordered) } + .padding(14).background(.thinMaterial) + Divider() + ZStack { + Color.black.opacity(0.92) + if let url { + if isImage { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): image.resizable().scaledToFit().padding(24) + case .failure: previewFailure("图片加载失败", icon: "photo.badge.exclamationmark") + default: ProgressView("正在加载图片…").tint(.white).foregroundStyle(.white) + } + } + } else if isVideo { + if nativeVideo { VideoPlayer(player: AVPlayer(url: url)).padding(12) } + else { externalPlayerPrompt } + } else { + WebPreview(url: url) + } + } else if !errorText.isEmpty { + previewFailure(errorText, icon: "eye.slash") + } else { + ProgressView("正在获取预览地址…").tint(.white).foregroundStyle(.white) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minWidth: 900, minHeight: 650) + .onExitCommand { dismiss() } + .task { do { url = try await model.remoteURL(for: file) } catch { errorText = error.localizedDescription } } + } + + private var externalPlayerPrompt: some View { + VStack(spacing: 16) { + Image(systemName: "play.rectangle.on.rectangle").font(.system(size: 44)).foregroundStyle(.white) + Text("建议使用外部播放器").font(.title3.weight(.semibold)).foregroundStyle(.white) + Text("该格式可能包含 AVPlayer 不支持的编码、音轨或字幕。已优先支持 IINA,其次 VLC。") + .font(.callout).foregroundStyle(.white.opacity(0.72)).multilineTextAlignment(.center) + Button { Task { await model.playWithExternalPlayer(file); dismiss() } } label: { Label("使用 IINA / VLC 播放", systemImage: "play.fill") } + .buttonStyle(.borderedProminent) + }.padding(32) + } + + @ViewBuilder private func previewFailure(_ text: String, icon: String) -> some View { + ContentUnavailableView(text, systemImage: icon).foregroundStyle(.white) + } +} + +private struct WebPreview: NSViewRepresentable { + let url: URL + func makeNSView(context: Context) -> WKWebView { let view = WKWebView(); view.setValue(false, forKey: "drawsBackground"); return view } + func updateNSView(_ view: WKWebView, context: Context) { if view.url != url { view.load(URLRequest(url: url)) } } +} + private struct FileInspector: View { @ObservedObject var model: AppModel let file: CloudFile? + var body: some View { VStack(alignment: .leading, spacing: 0) { if let file { - VStack(alignment: .leading, spacing: 16) { - HStack { Image(systemName: file.icon).font(.system(size: 30)).foregroundStyle(file.isDirectory ? .orange : .accentColor); Spacer(); Button { Task { await model.showDetails(file) } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.plain).help("刷新详情") } - Text(file.name).font(.title3.weight(.semibold)).lineLimit(2) - HStack { Label(file.typeName, systemImage: file.isDirectory ? "folder" : "doc"); Spacer(); Text(file.formattedSize).foregroundStyle(.secondary) }.font(.caption) + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top) { + Image(systemName: file.icon) + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(file.isDirectory ? .orange : .accentColor) + .frame(width: 58, height: 58) + .background((file.isDirectory ? Color.orange : Color.accentColor).opacity(0.12), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + Spacer() + Button { Task { await model.showDetails(file) } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.bordered).help("刷新详情") + } + 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 { Task { await model.share(file) } } label: { Image(systemName: "square.and.arrow.up") }.buttonStyle(.bordered).help("分享") + } Divider() - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 12) { InspectorField(title: "文件 ID", value: file.id) InspectorField(title: "GCID", value: file.gcid ?? "未获取") + InspectorField(title: "大小", value: file.formattedSize) + if file.isDirectory { + InspectorField(title: "子文件夹", value: file.subDirectoryCount.map(String.init) ?? "--") + InspectorField(title: "子文件", value: file.subFileCount.map(String.init) ?? "--") + } InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt) } - 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 { Task { await model.share(file) } } label: { Image(systemName: "square.and.arrow.up") }.buttonStyle(.bordered) } - if let detail = model.detail { Divider(); Text("接口详情").font(.caption.weight(.semibold)); ScrollView { Text(jsonText(detail)).font(.system(.caption, design: .monospaced)).textSelection(.enabled) }.frame(maxHeight: 220) } + 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) + } Spacer() }.padding(22) } else { - VStack(spacing: 12) { Image(systemName: "sidebar.right").font(.system(size: 32, weight: .light)).foregroundStyle(.tertiary); Text("选择一个文件").font(.headline); Text("详情和操作会显示在这里").font(.caption).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) + 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) } } - .frame(minWidth: 250, idealWidth: 290) - .padding(8) - .liquidGlass(cornerRadius: 18) - .padding(8) - .onChange(of: file?.id) { _, _ in model.detail = nil } + .frame(minWidth: 270, idealWidth: 310) + .softCard(radius: 24) + .padding(10) + .onChange(of: file?.id) { _, _ in model.detail = nil; model.detailFileID = nil } } } -private struct InspectorField: View { let title: String; let value: String; var body: some View { VStack(alignment: .leading, spacing: 3) { Text(title.uppercased()).font(.system(size: 9, weight: .semibold)).foregroundStyle(.tertiary); Text(value).font(.caption).lineLimit(2).textSelection(.enabled) } } } +private struct LoadingFilesView: View { + var body: some View { VStack(spacing: 12) { ProgressView(); Text("正在加载文件…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } +} + +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 { + @ObservedObject var model: AppModel + let tool: WorkspaceTool + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 14) { + Image(systemName: tool.icon).font(.title2.weight(.semibold)).foregroundStyle(.orange).frame(width: 52, height: 52).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 17, style: .continuous)) + VStack(alignment: .leading, spacing: 4) { Text(tool.rawValue).font(.system(size: 28, weight: .bold, design: .rounded)); Text(tool.subtitle).foregroundStyle(.secondary) } + Spacer() + } + .padding(20).softCard(radius: 24) + + switch tool { + case .scan: UnifiedScanToolPage(model: model) + case .tmdb: TMDBToolPage(model: model) + case .settings: ToolSettingsPage(model: model) + } + } + .padding(14) + .background(Color.clear) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + +private struct UnifiedScanToolPage: View { + @ObservedObject var model: AppModel + @State private var selectedRootID: String? + @State private var selectedRootName = "整个云盘" + @State private var showFolderPicker = false + + 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(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) } + } + .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) } + } + + 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 struct ScanToolPage: View { + @ObservedObject var model: AppModel + let kind: ScanKind + @State private var scope: ScanScope = .wholeDrive + @State private var selectedRootID: String? + @State private var selectedRootName = "云盘根目录" + @State private var excludeSmall = false + @State private var selectedIDs: Set = [] + @State private var showDeletionConfirmation = false + @State private var showFolderPicker = false + @State private var detailFolder: CloudFile? + + private var resultItems: [ScanItem] { + if model.activeScanRequest?.kind == kind, model.isScanning { return model.liveScanItems } + 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) + } + .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 = [] + } + } 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 selectedFileIDsForDeletion: Set { + Set(resultItems.flatMap { item -> [String] in + switch item { + case .emptyFolder(let folder): return selectedIDs.contains(item.id) ? [folder.id] : [] + case .duplicate(let group): return group.files.filter { selectedIDs.contains($0.id) }.map(\.id) + case .similar(let group): return group.folders.filter { selectedIDs.contains($0.id) }.map(\.id) + } + }) + } + + @ViewBuilder private func scanResultRow(_ item: ScanItem) -> some View { + switch item { + case .emptyFolder(let folder): + Toggle(isOn: Binding(get: { selectedIDs.contains(item.id) }, set: { if $0 { selectedIDs.insert(item.id) } else { selectedIDs.remove(item.id) } })) { HStack { Image(systemName: "folder").foregroundStyle(.orange); Text(folder.name); Spacer(); Text("空文件夹").font(.caption).foregroundStyle(.secondary); Text(folder.id).font(.caption.monospaced()).foregroundStyle(.secondary) } } + .padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) + .contextMenu { + Button { detailFolder = folder } label: { Label("查看详情", systemImage: "info.circle") } + Button(role: .destructive) { selectedIDs.insert(item.id); showDeletionConfirmation = true } label: { Label("删除此空文件夹…", systemImage: "trash") } + } + case .duplicate(let group): + VStack(alignment: .leading, spacing: 8) { + 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) + } + }.padding(12).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) + case .similar(let group): + VStack(alignment: .leading, spacing: 8) { + Text("相似名称组 · \(group.folders.count) 个文件夹(默认保留第一项)").font(.caption.weight(.bold)).foregroundStyle(.green) + ForEach(Array(group.folders.enumerated()), id: \.element.id) { index, folder in + Toggle(isOn: Binding(get: { selectedIDs.contains(folder.id) }, set: { if $0 { selectedIDs.insert(folder.id) } else { selectedIDs.remove(folder.id) } })) { + HStack { Image(systemName: "folder.fill").foregroundStyle(.orange); Text(folder.name); Spacer(); Text(index == 0 ? "保留" : folder.id).font(.caption.monospaced()).foregroundStyle(index == 0 ? .green : .secondary) } + }.disabled(index == 0) + } + }.padding(12).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) + } + } +} + +private struct CleanupToolPage: View { + @ObservedObject var model: AppModel + @State private var confirmed = false + var body: some View { + ToolPageCard(icon: "trash.slash", tint: .orange, title: "清理范围", detail: "将从云盘根目录递归扫描。只有完全没有子文件和子目录的文件夹会被删除。") { + Toggle("我已了解该操作会删除空文件夹", isOn: $confirmed) + Button { Task { await model.cleanEmptyFolders() } } label: { Label(model.isBusy ? "正在清理…" : "开始扫描并清理", systemImage: "sparkles") }.buttonStyle(.borderedProminent).tint(.orange).disabled(!confirmed || model.isBusy) + } + } +} + +private struct DuplicateToolPage: View { + @ObservedObject var model: AppModel + var body: some View { + VStack(spacing: 16) { + ToolPageCard(icon: "square.on.square", tint: .blue, title: "扫描选项", detail: "扫描当前列表中的文件,按 GCID 归组。") { + HStack { + Button { Task { await model.scanDuplicates() } } label: { Label("扫描全部文件", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).tint(.blue) + Button { Task { await model.scanDuplicates(excludingSmallFiles: true) } } label: { Label("排除小于 1 MB", systemImage: "line.3.horizontal.decrease.circle") }.buttonStyle(.bordered) + if model.isAnalyzing { ProgressView().controlSize(.small) } + } + } + ToolPageCard(icon: "list.bullet.rectangle", tint: .purple, title: "扫描结果", detail: model.duplicateGroups.isEmpty ? "尚未发现重复文件" : "发现 \(model.duplicateGroups.count) 组重复文件") { + if model.duplicateGroups.isEmpty { EmptyState(icon: "checkmark.seal", title: "暂无重复项", subtitle: "点击上方按钮开始扫描当前列表。") } + else { ForEach(model.duplicateGroups) { group in DuplicateGroupRow(group: group) } } + } + } + } +} + +private struct DuplicateGroupRow: View { + let group: DuplicateGroup + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack { Text("GCID \(group.id)").font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1); Spacer(); Text("\(group.files.count) 个").font(.caption.weight(.bold)).foregroundStyle(.orange) } + ForEach(group.files) { file in HStack { Image(systemName: file.icon).frame(width: 22); Text(file.name).lineLimit(1); Spacer(); Text(file.formattedSize).foregroundStyle(.secondary) }.font(.callout) } + }.padding(13).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +private struct SimilarFoldersToolPage: View { + @ObservedObject var model: AppModel + var body: some View { + ToolPageCard(icon: "rectangle.3.group", tint: .green, title: "当前目录相似文件夹", detail: "忽略空格、点号、括号、连字符和大小写后比较名称。") { + Button { model.findSimilarFolders() } label: { Label("查找相似文件夹", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).tint(.green) + if model.similarFolders.isEmpty { EmptyState(icon: "folder.badge.questionmark", title: "暂无结果", subtitle: "点击按钮扫描当前目录。") } + else { ForEach(model.similarFolders) { folder in HStack { Image(systemName: "folder.fill").foregroundStyle(.orange); Text(folder.name); Spacer(); Text(folder.formattedSize).foregroundStyle(.secondary) }.padding(11).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) } } + } + } +} + +private struct TMDBToolPage: View { + @ObservedObject var model: AppModel + @State private var workflow = TMDBWorkflowConfig.load() + @State private var sourceID: String? + @State private var sourceName = "云盘根目录" + @State private var destinationID: String? + @State private var destinationName = "云盘根目录" + @State private var showRunConfirmation = false + + var body: some View { + VStack(spacing: 16) { + ToolPageCard(icon: "film.stack", tint: .purple, title: "TMDB 刮削任务", detail: "按“选择范围 → 建立任务 → 预览匹配 → 确认执行”运行;预览阶段不会修改云盘。") { + HStack { Image(systemName: model.tmdbAPIKey.isEmpty ? "xmark.circle.fill" : "checkmark.circle.fill").foregroundStyle(model.tmdbAPIKey.isEmpty ? .red : .green); Text(model.tmdbAPIKey.isEmpty ? "请先在工作区设置配置 TMDB API Key" : "TMDB API Key 已配置") } + Picker("媒体类型", selection: $workflow.mediaKind) { ForEach(TMDBMediaKind.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) + Picker("执行方式", selection: $workflow.operation) { ForEach(TMDBOperation.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) + scopePicker(title: "源目录", name: $sourceName, id: $sourceID) + if workflow.operation == .organize { scopePicker(title: "目标目录", name: $destinationName, id: $destinationID) } + Toggle("递归扫描源目录", isOn: $workflow.sourceRecursive) + HStack { TextField("文件夹模板,例如 {title} ({year})", text: $workflow.folderTemplate); TextField("文件模板", text: $workflow.fileTemplate) }.textFieldStyle(.roundedBorder) + HStack { TextField("扩展名,逗号分隔", text: $workflow.allowedExtensions); Stepper("最小体积 \(workflow.minimumSizeMB) MB", value: $workflow.minimumSizeMB, in: 0...20_000, step: 50) }.textFieldStyle(.roundedBorder) + HStack { Toggle("写入 NFO", isOn: $workflow.createNFO); Toggle("下载海报(即将支持)", isOn: $workflow.downloadPoster).disabled(true); Toggle("清理空目录(即将支持)", isOn: $workflow.cleanEmptyFolders).disabled(true) } + HStack { Button("保存配置") { model.saveTMDBWorkflow(workflow) }.buttonStyle(.bordered); Spacer(); Button { model.saveTMDBWorkflow(workflow); Task { await model.prepareTMDBJobs(sourceID: sourceID, recursive: workflow.sourceRecursive, minimumSizeMB: workflow.minimumSizeMB, extensionsText: workflow.allowedExtensions) } } label: { Label("建立任务", systemImage: "list.bullet.clipboard") }.buttonStyle(.borderedProminent).tint(.purple).disabled(model.tmdbAPIKey.isEmpty || model.isRunningTMDBJob) } + } + ToolPageCard(icon: "checklist", tint: .blue, title: "任务预览与执行", detail: model.tmdbJobs.isEmpty ? "尚未建立任务。" : "共 \(model.tmdbJobs.count) 项;先匹配,再确认执行。") { + if model.tmdbJobs.isEmpty { EmptyState(icon: "film", title: "等待建立任务", subtitle: "任务会按扩展名、最小体积和扫描范围过滤。") } + else { + HStack { Button { Task { await model.matchTMDBJobs() } } label: { Label("搜索候选", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).disabled(model.isRunningTMDBJob); Button { showRunConfirmation = true } label: { Label(workflow.operation == .preview ? "预览模式,不执行整理" : "确认执行整理", systemImage: "play.fill") }.buttonStyle(.bordered).disabled(model.isRunningTMDBJob || workflow.operation == .preview); if model.isRunningTMDBJob { ProgressView().controlSize(.small) } } + ForEach(model.tmdbJobs) { job in + VStack(alignment: .leading, spacing: 8) { + HStack { Image(systemName: job.state == .completed ? "checkmark.circle.fill" : (job.state == .failed ? "xmark.circle.fill" : "film" )).foregroundStyle(job.state == .failed ? .red : .purple); VStack(alignment: .leading) { Text(job.file.name).lineLimit(1); Text(job.note).font(.caption).foregroundStyle(.secondary).lineLimit(1) }; Spacer(); if job.selectedCandidate != nil { Toggle("执行", isOn: Binding(get: { job.isApproved }, set: { _ in model.toggleTMDBApproval(jobID: job.id) })).toggleStyle(.switch).labelsHidden() } } + if !job.candidates.isEmpty { Picker("选择 TMDB 匹配", selection: Binding(get: { job.selectedCandidateID }, set: { model.selectTMDBCandidate(jobID: job.id, candidateID: $0) })) { Text("跳过此项").tag(Int?.none); ForEach(job.candidates) { candidate in Text("\(candidate.title) \(candidate.releaseDate.prefix(4)) · \(candidate.mediaType.title)").tag(Optional(candidate.id)) } }.labelsHidden() } + if let candidate = job.selectedCandidate { Text(candidate.overview.isEmpty ? "暂无简介" : candidate.overview).font(.caption).foregroundStyle(.secondary).lineLimit(2) } + }.padding(11).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 11)) + } + } + } + } + .confirmationDialog("确认执行已选择的 TMDB 整理任务?", isPresented: $showRunConfirmation) { + Button("开始整理", role: .destructive) { Task { await model.runTMDBJobs(destinationID: destinationID) } } + } message: { Text("只会处理你手动选择并开启“执行”的条目。媒体文件将移动到目标目录;NFO 会在移动后写入。") } + .onAppear { workflow = model.tmdbWorkflow } + } + + private func scopePicker(title: String, name: Binding, id: Binding) -> some View { + HStack { VStack(alignment: .leading, spacing: 2) { Text(title).font(.caption).foregroundStyle(.secondary); Text(name.wrappedValue).font(.callout.weight(.semibold)).lineLimit(1) }; Spacer(); Button("使用当前目录") { id.wrappedValue = model.currentScanRootID; name.wrappedValue = model.scanScopeLocationName }.buttonStyle(.bordered); Button("根目录") { id.wrappedValue = nil; name.wrappedValue = "云盘根目录" }.buttonStyle(.bordered) }.padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) + } +} + +private struct ToolSettingsPage: View { + @ObservedObject var model: AppModel + @State private var key = "" + @State private var proxyHost = "" + @State private var proxyPort = "" + 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) } + }.onAppear { key = model.tmdbAPIKey; proxyHost = model.tmdbProxyHost; proxyPort = model.tmdbProxyPort } + } +} + +private struct ToolPageCard: View { + let icon: String + let tint: Color + let title: String + let detail: String + @ViewBuilder let content: Content + init(icon: String, tint: Color, title: String, detail: String, @ViewBuilder content: () -> Content) { self.icon = icon; self.tint = tint; self.title = title; self.detail = detail; self.content = content() } + var body: some View { + VStack(alignment: .leading, spacing: 15) { + HStack(spacing: 11) { Image(systemName: icon).foregroundStyle(tint).frame(width: 36, height: 36).background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)); VStack(alignment: .leading, spacing: 3) { Text(title).font(.headline); Text(detail).font(.caption).foregroundStyle(.secondary) }; Spacer() } + Divider(); content + }.padding(18).softCard(radius: 22) + } +} private struct CreateFolderSheet: View { @Binding var name: String let onCreate: (String) -> Void let onCancel: () -> Void - var body: some View { VStack(alignment: .leading, spacing: 16) { Text("新建文件夹").font(.title3.bold()); TextField("文件夹名称", text: $name).textFieldStyle(.roundedBorder); HStack { Spacer(); Button("取消", action: onCancel); Button("创建") { onCreate(name) }.buttonStyle(.borderedProminent).tint(.orange) } }.padding(24).frame(width: 340) } + @FocusState private var focused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + SheetHeader(icon: "folder.badge.plus", title: "新建文件夹", subtitle: "在当前目录创建一个新的云端文件夹") + TextField("文件夹名称", text: $name) + .textFieldStyle(.plain) + .focused($focused) + .padding(.horizontal, 13) + .frame(height: 42) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: 11).stroke(focused ? Color.orange : Color.black.opacity(0.10), lineWidth: 1) } + .onSubmit { createIfPossible() } + HStack { Spacer(); Button("取消", action: onCancel).keyboardShortcut(.cancelAction); Button("创建") { createIfPossible() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + } + .padding(24) + .frame(width: 380) + .softCard(radius: 24) + .onAppear { focused = true } + } + + private func createIfPossible() { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onCreate(trimmed) + } } private struct RenameSheet: View { @@ -551,8 +1517,39 @@ private struct RenameSheet: View { let onRename: (String) -> Void @Environment(\.dismiss) private var dismiss @State private var name: String - init(file: CloudFile, onRename: @escaping (String) -> Void) { self.file = file; self.onRename = onRename; _name = State(initialValue: file.name) } - var body: some View { VStack(alignment: .leading, spacing: 16) { Text("重命名").font(.title3.bold()); TextField("名称", text: $name).textFieldStyle(.roundedBorder); HStack { Spacer(); Button("取消") { dismiss() }; Button("保存") { onRename(name); dismiss() }.buttonStyle(.borderedProminent).tint(.orange) } }.padding(24).frame(width: 340) } + @FocusState private var focused: Bool + + init(file: CloudFile, onRename: @escaping (String) -> Void) { + self.file = file + self.onRename = onRename + _name = State(initialValue: file.name) + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + SheetHeader(icon: "pencil", title: "重命名", subtitle: file.name) + TextField("名称", text: $name) + .textFieldStyle(.plain) + .focused($focused) + .padding(.horizontal, 13) + .frame(height: 42) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: 11).stroke(focused ? Color.orange : Color.black.opacity(0.10), lineWidth: 1) } + .onSubmit { saveIfPossible() } + HStack { Spacer(); Button("取消") { dismiss() }.keyboardShortcut(.cancelAction); Button("保存") { saveIfPossible() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || name == file.name) } + } + .padding(24) + .frame(width: 400) + .softCard(radius: 24) + .onAppear { focused = true } + } + + private func saveIfPossible() { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onRename(trimmed) + dismiss() + } } private struct WorkspaceSettings: View { @@ -561,26 +1558,28 @@ private struct WorkspaceSettings: View { @State private var key = "" @State private var proxyHost = "" @State private var proxyPort = "" + var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 10) { - Image(systemName: "gearshape.2.fill").font(.title2).foregroundStyle(.orange) - Text("工作区设置").font(.title3.bold()) + VStack(alignment: .leading, spacing: 18) { + SheetHeader(icon: "gearshape.2.fill", title: "工作区设置", subtitle: "配置本机工具能力,不会上传到云端") + VStack(alignment: .leading, spacing: 10) { + Label("TMDB 识别", systemImage: "film.fill").font(.caption.weight(.bold)).foregroundStyle(.secondary) + SecureField("本地记住的 API Key", text: $key).textFieldStyle(.roundedBorder) } - Text("TMDB 识别").font(.caption.weight(.semibold)).foregroundStyle(.secondary) - SecureField("本地记住的 API Key", text: $key).textFieldStyle(.roundedBorder) - Text("HTTP 代理").font(.caption.weight(.semibold)).foregroundStyle(.secondary) - HStack(spacing: 8) { - TextField("地址,例如 127.0.0.1", text: $proxyHost).textFieldStyle(.roundedBorder) - TextField("端口", text: $proxyPort).textFieldStyle(.roundedBorder).frame(width: 88) + VStack(alignment: .leading, spacing: 10) { + Label("HTTP 代理", systemImage: "network").font(.caption.weight(.bold)).foregroundStyle(.secondary) + HStack(spacing: 8) { + TextField("地址,例如 127.0.0.1", text: $proxyHost).textFieldStyle(.roundedBorder) + TextField("端口", text: $proxyPort).textFieldStyle(.roundedBorder).frame(width: 88) + } + Text("代理仅用于 TMDB 请求。留空表示直连。") + .font(.caption).foregroundStyle(.secondary) } - Text("代理仅用于 TMDB 请求,配置保存在本机。留空表示直连。") - .font(.caption).foregroundStyle(.secondary) - HStack { Spacer(); Button("取消") { dismiss() }; Button("保存") { model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort); dismiss() }.buttonStyle(.borderedProminent).tint(.orange) } + HStack { Spacer(); Button("取消") { dismiss() }.keyboardShortcut(.cancelAction); Button("保存") { model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort); dismiss() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction) } } .padding(24) - .frame(width: 500) - .liquidGlass(cornerRadius: 20) + .frame(width: 520) + .softCard(radius: 24) .onAppear { key = model.tmdbAPIKey; proxyHost = model.tmdbProxyHost; proxyPort = model.tmdbProxyPort } } } @@ -592,18 +1591,20 @@ private struct ToolsPanel: View { @Environment(\.dismiss) private var dismiss var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { Text("工作区工具").font(.title3.bold()); Spacer(); Button("关闭") { dismiss() } } - ToolAction(title: "清理空文件夹", detail: "递归扫描当前云盘并删除空目录", icon: "sparkles") { Task { await model.cleanEmptyFolders(); dismiss() } } - ToolAction(title: "扫描重复文件", detail: "按 GCID 查找重复资源", icon: "square.on.square") { Task { await model.scanDuplicates(); isShowingDuplicates = true; dismiss() } } - ToolAction(title: "排除小文件扫描", detail: "忽略小于 1 MB 的文件", icon: "square.on.square.fill") { Task { await model.scanDuplicates(excludingSmallFiles: true); isShowingDuplicates = true; dismiss() } } - ToolAction(title: "查询相似文件夹", detail: "按标准化名称查找相似目录", icon: "rectangle.3.group") { model.findSimilarFolders(); dismiss() } + 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(22) - .frame(width: 420) - .liquidGlass(cornerRadius: 20) + .padding(24) + .frame(width: 620) + .softCard(radius: 26) } } @@ -611,15 +1612,27 @@ 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) { - HStack(spacing: 12) { - Image(systemName: icon).font(.title3).foregroundStyle(.orange).frame(width: 28) - VStack(alignment: .leading, spacing: 3) { Text(title).font(.body.weight(.medium)); Text(detail).font(.caption).foregroundStyle(.secondary) } - Spacer() - Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) - }.contentShape(Rectangle()) + 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) } } @@ -628,160 +1641,93 @@ private struct DuplicateResults: View { @ObservedObject var model: AppModel @Environment(\.dismiss) private var dismiss var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text("重复文件").font(.title3.bold()) - Spacer() - Button("关闭") { dismiss() } - } + VStack(alignment: .leading, spacing: 16) { + HStack { SheetHeader(icon: "square.on.square", title: "重复文件", subtitle: model.duplicateGroups.isEmpty ? "按 GCID 扫描当前列表" : "发现 \(model.duplicateGroups.count) 组重复资源"); Spacer(); Button("关闭") { dismiss() } } if model.isAnalyzing { - ProgressView("正在按 GCID 扫描…") + VStack(spacing: 12) { ProgressView(); Text("正在按 GCID 扫描…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, minHeight: 260) } else if model.duplicateGroups.isEmpty { - Text("没有发现重复文件").foregroundStyle(.secondary) + EmptyState(icon: "checkmark.seal", title: "没有发现重复文件", subtitle: "当前目录看起来很干净。") + .frame(maxWidth: .infinity, minHeight: 260) } else { List(model.duplicateGroups) { group in - VStack(alignment: .leading, spacing: 6) { - Text("GCID \(group.id)").font(.caption.monospaced()).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("GCID").font(.caption.weight(.bold)).foregroundStyle(.secondary) + Text(group.id).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) + Spacer() + Text("\(group.files.count) 个").font(.caption.weight(.bold)).foregroundStyle(.orange) + } ForEach(group.files) { file in - HStack { - Image(systemName: file.icon) + HStack(spacing: 10) { + Image(systemName: file.icon).foregroundStyle(file.isDirectory ? .orange : .accentColor).frame(width: 24) Text(file.name).lineLimit(1) Spacer() - Text(file.formattedSize).foregroundStyle(.secondary) + Text(file.formattedSize).foregroundStyle(.secondary).monospacedDigit() } + .font(.callout) } } + .padding(.vertical, 8) } - .frame(minHeight: 260) + .listStyle(.inset) + .frame(minHeight: 320) } } - .padding(20) - .frame(width: 560, height: 430) + .padding(24) + .frame(width: 640, height: 500) + .softCard(radius: 26) + } +} + +private struct SheetHeader: View { + let icon: String + let title: String + let subtitle: String + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title3.weight(.semibold)) + .foregroundStyle(.orange) + .frame(width: 42, height: 42) + .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.title3.bold()) + Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + } + } +} + +private struct EmptyState: View { + let icon: String + let title: String + let subtitle: String + var body: some View { + VStack(spacing: 12) { + Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.tertiary) + Text(title).font(.title3.weight(.semibold)) + Text(subtitle).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) + } + .padding(28) } } private func jsonText(_ value: JSONValue) -> String { guard let data = try? JSONEncoder().encode(value), let object = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), let text = String(data: pretty, encoding: .utf8) else { return "--" }; return text } -private struct LegacyWorkspaceView: View { - @ObservedObject var model: AppModel - var body: some View { - NavigationSplitView { LegacyWorkspaceSidebar(model: model) } detail: { LegacyFilesView(model: model) } - .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 270).frame(minWidth: 980, minHeight: 620) - .onChange(of: model.section) { _, _ in Task { await model.refresh() } } - } -} - -private struct LegacyWorkspaceSidebar: View { - @ObservedObject var model: AppModel - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 10) { BrandMark(size: 30); Text("光鸭云盘").font(.headline); Spacer() }.padding(.horizontal, 16).padding(.vertical, 18) - List(selection: $model.section) { - Section("文件空间") { ForEach([WorkspaceSection.files, .photos, .videos, .documents]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } } - Section("更多") { ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } } - }.listStyle(.sidebar) - Divider() - HStack(spacing: 10) { - Circle().fill(Color.orange.opacity(0.18)).frame(width: 32, height: 32).overlay { Text(String(model.user.name.prefix(1))).font(.headline).foregroundStyle(.orange) } - VStack(alignment: .leading, spacing: 2) { Text(model.user.name).font(.callout.weight(.medium)).lineLimit(1); Text(model.user.phone).font(.caption).foregroundStyle(.secondary).lineLimit(1) } - Spacer() - Button { model.logout() } label: { Image(systemName: "rectangle.portrait.and.arrow.right") }.buttonStyle(.plain).help("退出登录") - }.padding(14) - }.background(Color(nsColor: .controlBackgroundColor)) - } -} - -private struct LegacyFilesView: View { - @ObservedObject var model: AppModel - @State private var searchText = "" - @State private var isImporting = false - @State private var isCreatingFolder = false - @State private var folderName = "" - private var filteredFiles: [CloudFile] { searchText.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(searchText) } } - var body: some View { - VStack(spacing: 0) { - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 6) { - Text(model.section.rawValue).font(.system(size: 26, weight: .bold)) - if model.section == .files && !model.folderPath.isEmpty { - HStack(spacing: 5) { - Button("全部文件") { Task { await model.navigateToRoot() } }.buttonStyle(.link) - ForEach(Array(model.folderPath.enumerated()), id: \.element.id) { index, folder in - Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary) - Button(folder.name) { Task { await model.navigateToFolder(at: index) } }.buttonStyle(.link).lineLimit(1) - } - }.font(.caption) - } else { - Text(model.section == .files ? "全部文件" : "按类型整理的文件").font(.subheadline).foregroundStyle(.secondary) - } - } - Spacer() - TextField("搜索文件", text: $searchText).textFieldStyle(.roundedBorder).frame(width: 190) - Button { isImporting = true } label: { Image(systemName: "arrow.up") }.buttonStyle(.bordered).help("上传文件") - 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("刷新") - .disabled(model.isBusy || model.isLoadingFiles) - }.padding(.horizontal, 28).padding(.vertical, 24) - Divider() - if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section) } - else if model.isLoadingFiles && model.files.isEmpty { VStack(spacing: 12) { ProgressView(); Text("正在加载文件…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } - else if filteredFiles.isEmpty { EmptyFilesView(model: model) } - else { - List { - ForEach(filteredFiles) { file in - Group { - if file.isDirectory { - Button { Task { await model.openFolder(file) } } label: { LegacyFileRow(file: file) } - .buttonStyle(.plain) - } else { - LegacyFileRow(file: file) - } - } - .contextMenu { Button(role: .destructive) { Task { await model.delete(file) } } label: { Label("删除", systemImage: "trash") } } - } - }.listStyle(.inset) - } - } - .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) } } - } - .sheet(isPresented: $isCreatingFolder) { - VStack(alignment: .leading, spacing: 16) { - Text("新建文件夹").font(.title3.bold()); TextField("文件夹名称", text: $folderName).textFieldStyle(.roundedBorder) - HStack { Spacer(); Button("取消") { isCreatingFolder = false }; Button("创建") { isCreatingFolder = false; Task { await model.createFolder(name: folderName); folderName = "" } }.buttonStyle(.borderedProminent) } - }.padding(24).frame(width: 330) - } - } -} - -private struct LegacyFileRow: View { - let file: CloudFile - var body: some View { - HStack(spacing: 14) { - Image(systemName: file.icon).font(.title3).foregroundStyle(file.isDirectory ? .orange : .accentColor).frame(width: 28) - VStack(alignment: .leading, spacing: 3) { - Text(file.name).font(.body).lineLimit(1) - HStack(spacing: 6) { - Text(file.typeName) - if file.isDirectory && file.size != nil { - Text("·") - Text(file.formattedSize) - } - }.font(.caption).foregroundStyle(.secondary) - } - Spacer(); Text(file.formattedSize).font(.callout).foregroundStyle(.secondary).frame(width: 90, alignment: .trailing); Text(file.modifiedAt.isEmpty ? "--" : file.modifiedAt).font(.callout).foregroundStyle(.secondary).frame(width: 140, alignment: .trailing) - }.padding(.vertical, 8).frame(maxWidth: .infinity, alignment: .leading) - } -} - private struct EmptyFilesView: View { @ObservedObject var model: AppModel - var body: some View { VStack(spacing: 12) { Image(systemName: model.section.icon).font(.system(size: 46, weight: .light)).foregroundStyle(.tertiary); Text("这里还没有文件").font(.title3.weight(.medium)); Text("上传文件或创建文件夹开始使用").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } + var body: some View { + EmptyState(icon: model.section.icon, title: "这里还没有文件", subtitle: model.section == .recycle ? "回收站为空,暂时没有可恢复内容。" : "上传文件或创建文件夹开始使用。") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } private struct EmptySectionView: View { let section: WorkspaceSection - var body: some View { VStack(spacing: 12) { Image(systemName: section.icon).font(.system(size: 46, weight: .light)).foregroundStyle(.tertiary); Text(section == .cloud ? "云下载" : "我的分享").font(.title3.weight(.medium)); Text("接口已就绪,登录后可以在这里管理内容").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } + var body: some View { + EmptyState(icon: section.icon, title: section == .cloud ? "云下载" : "我的分享", subtitle: "接口已就绪,后续可以在这里管理相关内容。") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } private struct BrandMark: View {